textmachine/backend/internal/llm/provider_anthropic_test.go

122 lines
4.2 KiB
Go

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: the leading system messages became system blocks, cache_control sits
// on the marked block, with a 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 invariant: 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 parameters are not sent on this wire: current Claude models
// reject temperature with a 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")
}
}