package llm import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" ) func TestAnthropicWireShapeAndUsage(t *testing.T) { var got map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { t.Errorf("path = %s", r.URL.Path) } if r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") == "" { t.Errorf("headers: key=%q version=%q", r.Header.Get("x-api-key"), r.Header.Get("anthropic-version")) } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Fatal(err) } w.Write([]byte(`{"id":"msg1","model":"claude-sonnet-5","content":[{"type":"text","text":"пере"},{"type":"text","text":"вод"}], "stop_reason":"end_turn", "usage":{"input_tokens":60,"output_tokens":20,"cache_creation_input_tokens":30,"cache_read_input_tokens":10}}`)) })) defer srv.Close() c := NewAnthropicClient(AnthropicConfig{BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), CacheTTL: "5m"}, nil) resp, err := c.Complete(context.Background(), LLMRequest{ Model: "claude-sonnet-5", Messages: []Message{ {Role: "system", Content: "роль"}, {Role: "system", Content: "бриф", CacheBoundary: true}, {Role: "user", Content: "чанк"}, }, MaxTokens: 512, Temperature: 0.4, }) if err != nil { t.Fatal(err) } // Wire: ведущие system-сообщения стали system-блоками, cache_control — на // помеченном блоке, с TTL. sys, ok := got["system"].([]any) if !ok || len(sys) != 2 { t.Fatalf("system blocks = %v", got["system"]) } first := sys[0].(map[string]any) if _, hasCC := first["cache_control"]; hasCC { t.Fatal("unmarked block must not carry cache_control") } second := sys[1].(map[string]any) cc, ok := second["cache_control"].(map[string]any) if !ok || cc["type"] != "ephemeral" || cc["ttl"] != "5m" { t.Fatalf("cache_control = %v", second["cache_control"]) } msgs, ok := got["messages"].([]any) if !ok || len(msgs) != 1 { t.Fatalf("messages = %v", got["messages"]) } // Usage-инвариант: PromptTokens = input + cache_read + cache_creation. if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 10 || resp.Usage.CacheCreationTokens != 30 { t.Fatalf("usage = %+v", resp.Usage) } if resp.Text != "перевод" || resp.FinishReason != FinishStop || resp.Model != "claude-sonnet-5" { t.Fatalf("resp = %+v", resp) } // Sampling-параметры на этот wire не отправляются: актуальные Claude-модели // отклоняют temperature с 400. if _, has := got["temperature"]; has { t.Fatal("temperature must not be sent to the Anthropic wire") } } func TestAnthropicRejectsUnmappedReasoning(t *testing.T) { c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil) _, err := c.Complete(context.Background(), LLMRequest{ Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16, ReasoningEffort: "high", }) if err == nil { t.Fatal("unmapped reasoning depth must fail loud, not silently run without thinking") } } func TestAnthropicStopReasonMapping(t *testing.T) { cases := map[string]string{ "end_turn": FinishStop, "max_tokens": FinishLength, "refusal": FinishRefusal, "weird": "", } for wire, want := range cases { if got := mapAnthropicStopReason(wire); got != want { t.Errorf("map(%q) = %q, want %q", wire, got, want) } } } func TestAnthropicTooManyBoundaries(t *testing.T) { c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil) msgs := make([]Message, 5) for i := range msgs { msgs[i] = Message{Role: "system", Content: "x", CacheBoundary: true} } _, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: msgs, MaxTokens: 16}) if err == nil { t.Fatal("more than 4 cache boundaries must fail loud (API limit)") } } func TestAnthropicJSONOnlyUnsupported(t *testing.T) { c := NewAnthropicClient(AnthropicConfig{BaseURL: "http://unused", APIKey: "k", Profile: fastProfile()}, nil) _, err := c.Complete(context.Background(), LLMRequest{ Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16, JSONOnly: true, }) if err == nil { t.Fatal("JSONOnly must fail loud, not silently drop the constraint") } }