返回 DeepSeek-Reasonix
anthropic_test.go
根目录 / internal / provider / anthropic / anthropic_test.go
1 package anthropic
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12
13 "reasonix/internal/provider"
14 )
15
16 // TestBuildRequest covers the protocol conversion: system lift, tool_use /
17 // tool_result blocks, coalescing consecutive tool results into one user turn,
18 // cache_control placement, and the max_tokens fallback.
19 func TestBuildRequest(t *testing.T) {
20 c := &client{name: "anthropic", model: "claude-opus-4-8"}
21 req := provider.Request{
22 Messages: []provider.Message{
23 {Role: provider.RoleSystem, Content: "You are helpful."},
24 {Role: provider.RoleUser, Content: "weather in Paris and Berlin?"},
25 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
26 {ID: "t1", Name: "get_weather", Arguments: `{"city":"Paris"}`},
27 {ID: "t2", Name: "get_weather", Arguments: `{"city":"Berlin"}`},
28 }},
29 {Role: provider.RoleTool, ToolCallID: "t1", Content: "sunny"},
30 {Role: provider.RoleTool, ToolCallID: "t2", Content: "cloudy"},
31 },
32 Tools: []provider.ToolSchema{{Name: "get_weather", Description: "w", Parameters: json.RawMessage(`{"type":"object"}`)}},
33 }
34 r := c.buildRequest(context.Background(), req)
35
36 if r.Model != "claude-opus-4-8" {
37 t.Fatalf("model = %q", r.Model)
38 }
39 if r.MaxTokens != defaultMaxTokens {
40 t.Fatalf("max_tokens = %d, want default %d", r.MaxTokens, defaultMaxTokens)
41 }
42 // System lifted to the top level, with a cache breakpoint on its last block.
43 if len(r.System) != 1 || r.System[0].Text != "You are helpful." {
44 t.Fatalf("system = %+v", r.System)
45 }
46 if r.System[0].CacheControl == nil {
47 t.Fatal("system block should carry cache_control")
48 }
49 // System present ⇒ the tool does NOT also get a breakpoint (system caches tools).
50 if r.Tools[0].CacheControl != nil {
51 t.Fatal("tool should not carry cache_control when system does")
52 }
53 // user, assistant(tool_use ×2), user(tool_result ×2 coalesced) = 3 messages.
54 if len(r.Messages) != 3 {
55 t.Fatalf("want 3 messages, got %d: %+v", len(r.Messages), r.Messages)
56 }
57 if r.Messages[0].Role != "user" || r.Messages[0].Content[0].Text != "weather in Paris and Berlin?" {
58 t.Fatalf("msg[0] = %+v", r.Messages[0])
59 }
60 if r.Messages[1].Role != "assistant" || len(r.Messages[1].Content) != 2 ||
61 r.Messages[1].Content[0].Type != "tool_use" || r.Messages[1].Content[0].ID != "t1" ||
62 string(r.Messages[1].Content[0].Input) != `{"city":"Paris"}` {
63 t.Fatalf("msg[1] = %+v", r.Messages[1])
64 }
65 last := r.Messages[2]
66 if last.Role != "user" || len(last.Content) != 2 {
67 t.Fatalf("tool results should coalesce into one user turn: %+v", last)
68 }
69 if last.Content[0].Type != "tool_result" || last.Content[0].ToolUseID != "t1" || last.Content[0].Content != "sunny" {
70 t.Fatalf("tool_result[0] = %+v", last.Content[0])
71 }
72 if last.Content[1].ToolUseID != "t2" {
73 t.Fatalf("tool_result[1] = %+v", last.Content[1])
74 }
75 // Conversation cache breakpoint on the last block of the last message.
76 if last.Content[len(last.Content)-1].CacheControl == nil {
77 t.Fatal("last message block should carry cache_control")
78 }
79 }
80
81 // TestBuildRequestNoSystem checks the breakpoint falls back to the last tool when
82 // there is no system message.
83 func TestBuildRequestNoSystem(t *testing.T) {
84 c := &client{model: "claude-opus-4-8"}
85 r := c.buildRequest(context.Background(), provider.Request{
86 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
87 Tools: []provider.ToolSchema{{Name: "a"}, {Name: "b"}},
88 MaxTokens: 1000,
89 })
90 if r.MaxTokens != 1000 {
91 t.Fatalf("explicit max_tokens should win: %d", r.MaxTokens)
92 }
93 if r.Tools[1].CacheControl == nil {
94 t.Fatal("last tool should carry cache_control when there is no system")
95 }
96 // A tool with no schema gets a minimal valid object schema.
97 if string(r.Tools[0].InputSchema) != `{"type":"object","properties":{}}` {
98 t.Fatalf("empty schema not defaulted: %s", r.Tools[0].InputSchema)
99 }
100 }
101
102 func TestBuildRequestKeepsDefaultCacheControlBytesStable(t *testing.T) {
103 c := &client{model: "claude-opus-4-8"}
104 req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}
105 want, err := json.Marshal(c.buildRequest(context.Background(), req))
106 if err != nil {
107 t.Fatalf("marshal first request: %v", err)
108 }
109 requestCtx := t.Context()
110 got, err := json.Marshal(c.buildRequest(requestCtx, req))
111 if err != nil {
112 t.Fatalf("marshal second request: %v", err)
113 }
114 if string(got) != string(want) {
115 t.Fatalf("request bytes changed with unrelated context:\nfirst: %s\nsecond: %s", want, got)
116 }
117 if strings.Contains(string(got), `"ttl"`) {
118 t.Fatalf("default cache_control unexpectedly opted into a TTL: %s", got)
119 }
120 }
121
122 func TestCacheControlOmitsTTLByDefault(t *testing.T) {
123 b, err := json.Marshal(ephemeral())
124 if err != nil {
125 t.Fatalf("marshal: %v", err)
126 }
127 if string(b) != `{"type":"ephemeral"}` {
128 t.Fatalf("default cache_control = %s, want byte-identical to every prior release", b)
129 }
130 }
131
132 func TestConfiguredMaxOutputTokensRespectsMandatoryAnthropicFallback(t *testing.T) {
133 configured, err := New(provider.Config{
134 Name: "anthropic", Model: "claude-opus-4-8",
135 Extra: map[string]any{"max_output_tokens": 8192},
136 })
137 if err != nil {
138 t.Fatalf("New configured provider: %v", err)
139 }
140 if got := configured.(*client).buildRequest(context.Background(), provider.Request{}).MaxTokens; got != 8192 {
141 t.Fatalf("configured max_tokens = %d, want 8192", got)
142 }
143
144 disabled, err := New(provider.Config{
145 Name: "anthropic", Model: "claude-opus-4-8",
146 Extra: map[string]any{"max_output_tokens": -1},
147 })
148 if err != nil {
149 t.Fatalf("New disabled provider: %v", err)
150 }
151 if got := disabled.(*client).buildRequest(context.Background(), provider.Request{}).MaxTokens; got != defaultMaxTokens {
152 t.Fatalf("mandatory max_tokens fallback = %d, want %d", got, defaultMaxTokens)
153 }
154 }
155
156 func TestNewSelectsMaxOutputTokenDefaultByEndpoint(t *testing.T) {
157 for _, tc := range []struct {
158 name string
159 baseURL string
160 extra map[string]any
161 want int
162 }{
163 {name: "native anthropic", want: provider.DefaultOrdinaryOutputTokens},
164 {name: "unknown compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: provider.DefaultOrdinaryOutputTokens},
165 {name: "official deepseek", baseURL: "https://api.deepseek.com/anthropic", want: provider.DeepSeekMaxOutputTokens},
166 {name: "official deepseek high", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"effort": "high"}, want: provider.DeepSeekMaxOutputTokens},
167 {name: "official deepseek thinking off", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"effort": "disabled"}, want: provider.DeepSeekMaxOutputTokens},
168 {name: "explicit override", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"max_output_tokens": 8192}, want: 8192},
169 } {
170 t.Run(tc.name, func(t *testing.T) {
171 p, err := New(provider.Config{
172 Name: "test",
173 BaseURL: tc.baseURL,
174 Model: "model",
175 Extra: tc.extra,
176 })
177 if err != nil {
178 t.Fatalf("New: %v", err)
179 }
180 if got := p.(*client).defaultMaxTokens; got != tc.want {
181 t.Fatalf("defaultMaxTokens = %d, want %d", got, tc.want)
182 }
183 })
184 }
185 }
186
187 func TestStreamAnnotatesIndexedToolSchemaError(t *testing.T) {
188 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189 w.WriteHeader(http.StatusBadRequest)
190 _, _ = w.Write([]byte(`{"error":{"message":"Tool 1 function has invalid 'parameters' schema"}}`))
191 }))
192 defer srv.Close()
193
194 p, err := New(provider.Config{Name: "mimo-anthropic", BaseURL: srv.URL, Model: "mimo-v2.5-pro", APIKey: "k"})
195 if err != nil {
196 t.Fatalf("New: %v", err)
197 }
198 _, err = p.Stream(context.Background(), provider.Request{
199 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
200 Tools: []provider.ToolSchema{
201 {Name: "read_file", Parameters: json.RawMessage(`{"type":"object"}`)},
202 {Name: "mcp__files__search", Parameters: json.RawMessage(`{"type":"object"}`)},
203 },
204 })
205 var apiErr *provider.APIError
206 if !errors.As(err, &apiErr) || !strings.Contains(apiErr.ToolContext, `MCP server "files"`) {
207 t.Fatalf("Stream error = %v, want MCP tool source context", err)
208 }
209 }
210
211 func TestBuildRequestScopesLegacyTupleMigrationToMiMo(t *testing.T) {
212 legacy := json.RawMessage(`{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"string"},{"type":"number"}]}}}`)
213 req := provider.Request{Tools: []provider.ToolSchema{{Name: "tuple", Parameters: legacy}}}
214
215 mimo := (&client{mimo: true}).buildRequest(context.Background(), req)
216 if got := string(mimo.Tools[0].InputSchema); !strings.Contains(got, `"prefixItems"`) || strings.Contains(got, `"items":[`) {
217 t.Fatalf("MiMo parameters = %s, want Draft 2020-12 tuple keywords", got)
218 }
219
220 other := (&client{}).buildRequest(context.Background(), req)
221 if got := string(other.Tools[0].InputSchema); got != string(legacy) {
222 t.Fatalf("non-MiMo parameters changed:\n got: %s\nwant: %s", got, legacy)
223 }
224 }
225
226 func TestNewDetectsMiMoSchemaDialect(t *testing.T) {
227 for _, tc := range []struct {
228 baseURL string
229 want bool
230 }{
231 {"https://api.xiaomimimo.com/anthropic", true},
232 {"https://token-plan-cn.xiaomimimo.com/anthropic", true},
233 {"https://token-plan-sgp.xiaomimimo.com/anthropic", true},
234 {"https://token-plan-ams.xiaomimimo.com/anthropic", true},
235 {"https://api.anthropic.com", false},
236 {"https://api.minimaxi.com/anthropic", false},
237 } {
238 p, err := New(provider.Config{Name: "test", BaseURL: tc.baseURL, Model: "model"})
239 if err != nil {
240 t.Fatalf("New(%q): %v", tc.baseURL, err)
241 }
242 if got := p.(*client).mimo; got != tc.want {
243 t.Errorf("New(%q).mimo = %v, want %v", tc.baseURL, got, tc.want)
244 }
245 }
246 }
247
248 func TestNewDetectsOfficialDeepSeekEndpoint(t *testing.T) {
249 for _, tc := range []struct {
250 baseURL string
251 want bool
252 }{
253 {"https://api.deepseek.com/anthropic", true},
254 {"https://api.deepseek.com/anthropic/v1", true},
255 {"https://proxy.example.com/anthropic", false},
256 } {
257 p, err := New(provider.Config{Name: "test", BaseURL: tc.baseURL, Model: "deepseek-v4-flash"})
258 if err != nil {
259 t.Fatalf("New(%q): %v", tc.baseURL, err)
260 }
261 if got := p.(*client).deepseek; got != tc.want {
262 t.Errorf("New(%q).deepseek = %v, want %v", tc.baseURL, got, tc.want)
263 }
264 }
265 }
266
267 func TestNewScopesNativeCacheWritePricingToAnthropic(t *testing.T) {
268 for _, tc := range []struct {
269 name string
270 baseURL string
271 want bool
272 }{
273 {name: "default", want: true},
274 {name: "official v1", baseURL: "https://api.anthropic.com/v1", want: true},
275 {name: "compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: false},
276 } {
277 t.Run(tc.name, func(t *testing.T) {
278 p, err := New(provider.Config{Name: "test", BaseURL: tc.baseURL, Model: "claude-sonnet-4-6"})
279 if err != nil {
280 t.Fatalf("New: %v", err)
281 }
282 if got := p.(*client).nativeAnthropic; got != tc.want {
283 t.Fatalf("nativeAnthropic = %v, want %v", got, tc.want)
284 }
285 })
286 }
287 }
288
289 func TestMapStopReason(t *testing.T) {
290 cases := map[string]string{
291 "end_turn": "stop",
292 "stop_sequence": "stop",
293 "tool_use": "tool_calls",
294 "max_tokens": "length",
295 "refusal": "refusal",
296 "": "",
297 }
298 for in, want := range cases {
299 if got := mapStopReason(in); got != want {
300 t.Errorf("mapStopReason(%q) = %q, want %q", in, got, want)
301 }
302 }
303 }
304
305 const sseFixture = `event: message_start
306 data: {"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":50,"output_tokens":1}}}
307
308 event: content_block_start
309 data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}}
310
311 event: content_block_delta
312 data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
313
314 event: content_block_delta
315 data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}
316
317 event: content_block_stop
318 data: {"type":"content_block_stop","index":0}
319
320 event: content_block_start
321 data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather"}}
322
323 event: content_block_delta
324 data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
325
326 event: content_block_delta
327 data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"Paris\"}"}}
328
329 event: content_block_stop
330 data: {"type":"content_block_stop","index":1}
331
332 event: message_delta
333 data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":25}}
334
335 event: message_stop
336 data: {"type":"message_stop"}
337 `
338
339 // TestReadStream feeds a canned Messages API SSE stream through readStream and
340 // asserts the emitted chunk sequence: text deltas, a tool-call start + complete,
341 // a usage record, then done.
342 func TestReadStream(t *testing.T) {
343 c := &client{name: "anthropic"}
344 resp := &http.Response{Body: io.NopCloser(strings.NewReader(sseFixture))}
345 ch := make(chan provider.Chunk)
346 go c.readStream(context.Background(), resp, ch)
347
348 var text strings.Builder
349 var started, full *provider.ToolCall
350 var usage *provider.Usage
351 done := false
352 for ck := range ch {
353 switch ck.Type {
354 case provider.ChunkText:
355 text.WriteString(ck.Text)
356 case provider.ChunkToolCallStart:
357 started = ck.ToolCall
358 case provider.ChunkToolCall:
359 full = ck.ToolCall
360 case provider.ChunkUsage:
361 usage = ck.Usage
362 case provider.ChunkDone:
363 done = true
364 case provider.ChunkError:
365 t.Fatalf("unexpected error chunk: %v", ck.Err)
366 }
367 }
368
369 if text.String() != "Hello world" {
370 t.Fatalf("text = %q", text.String())
371 }
372 if started == nil || started.ID != "toolu_1" || started.Name != "get_weather" {
373 t.Fatalf("tool start = %+v", started)
374 }
375 if full == nil || full.Arguments != `{"city":"Paris"}` {
376 t.Fatalf("tool full = %+v", full)
377 }
378 switch {
379 case usage == nil:
380 t.Fatal("expected a usage chunk")
381 case usage.PromptTokens != 150 || usage.CompletionTokens != 25 || usage.TotalTokens != 175:
382 t.Fatalf("usage tokens = %+v", usage)
383 case usage.CacheHitTokens != 50 || usage.CacheMissTokens != 100:
384 t.Fatalf("usage cache = hit %d miss %d", usage.CacheHitTokens, usage.CacheMissTokens)
385 case usage.FinishReason != "tool_calls":
386 t.Fatalf("finish reason = %q", usage.FinishReason)
387 }
388 if !done {
389 t.Fatal("expected a done chunk")
390 }
391 }
392
393 // TestReadStreamIgnoresTransportErrorAfterMessageStop: a complete stream that
394 // already received message_stop must finalize successfully even if the
395 // connection resets while draining the rest of the body.
396 func TestReadStreamIgnoresTransportErrorAfterMessageStop(t *testing.T) {
397 // message_stop arrives; the body then ends abruptly. We must not surface
398 // StreamInterruptedError after a clean terminal.
399 pr, pw := io.Pipe()
400 go func() {
401 _, _ = io.WriteString(pw, `event: message_start
402 data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":5}}}
403
404 event: content_block_delta
405 data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}
406
407 event: message_delta
408 data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}
409
410 event: message_stop
411 data: {"type":"message_stop"}
412 `)
413 // Simulate a post-terminal reset while the client might still be reading.
414 _ = pw.CloseWithError(io.ErrUnexpectedEOF)
415 }()
416 c := &client{name: "anthropic"}
417 resp := &http.Response{Body: pr}
418 ch := make(chan provider.Chunk)
419 go c.readStream(context.Background(), resp, ch)
420
421 var text strings.Builder
422 var sawDone, sawErr bool
423 for ck := range ch {
424 switch ck.Type {
425 case provider.ChunkText:
426 text.WriteString(ck.Text)
427 case provider.ChunkDone:
428 sawDone = true
429 case provider.ChunkError:
430 sawErr = true
431 t.Fatalf("post-terminal transport error must not surface: %v", ck.Err)
432 }
433 }
434 if text.String() != "ok" || !sawDone || sawErr {
435 t.Fatalf("text=%q done=%v err=%v", text.String(), sawDone, sawErr)
436 }
437 }
438
439 // TestReadStreamRequiresMessageStop: EOF after a complete tool block but before
440 // message_stop or message_delta.stop_reason must surface StreamInterruptedError
441 // so the attempt stays uncommitted (tool calls remain speculative).
442 func TestReadStreamRequiresMessageStop(t *testing.T) {
443 sse := `event: message_start
444 data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":10}}}
445
446 event: content_block_start
447 data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"bash"}}
448
449 event: content_block_delta
450 data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}
451
452 event: content_block_stop
453 data: {"type":"content_block_stop","index":0}
454 `
455 c := &client{name: "anthropic"}
456 resp := &http.Response{Body: io.NopCloser(strings.NewReader(sse))}
457 ch := make(chan provider.Chunk)
458 go c.readStream(context.Background(), resp, ch)
459
460 var gotInterrupted bool
461 var sawDone bool
462 for ck := range ch {
463 switch ck.Type {
464 case provider.ChunkDone:
465 sawDone = true
466 case provider.ChunkError:
467 var interrupted *provider.StreamInterruptedError
468 gotInterrupted = errors.As(ck.Err, &interrupted)
469 }
470 }
471 if sawDone {
472 t.Fatal("must not emit ChunkDone without message_stop")
473 }
474 if !gotInterrupted {
475 t.Fatal("EOF before message_stop must surface StreamInterruptedError")
476 }
477 }
478
479 // LongCat's Anthropic-compatible SSE stream can omit message_start.usage and
480 // report the complete usage object in message_delta. Those input/cache counters
481 // must not disappear from Reasonix metrics and billing estimates.
482 func TestReadStreamUsageFromMessageDelta(t *testing.T) {
483 sse := `event: message_start
484 data: {"type":"message_start","message":{"id":"msg_1"}}
485
486 event: content_block_delta
487 data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}
488
489 event: message_delta
490 data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":13,"output_tokens":3,"cache_creation_input_tokens":5,"cache_read_input_tokens":7}}
491
492 event: message_stop
493 data: {"type":"message_stop"}
494 `
495 c := &client{name: "longcat-anthropic"}
496 resp := &http.Response{Body: io.NopCloser(strings.NewReader(sse))}
497 ch := make(chan provider.Chunk)
498 go c.readStream(context.Background(), resp, ch)
499
500 var usage *provider.Usage
501 for ck := range ch {
502 if ck.Type == provider.ChunkError {
503 t.Fatalf("unexpected error chunk: %v", ck.Err)
504 }
505 if ck.Type == provider.ChunkUsage {
506 usage = ck.Usage
507 }
508 }
509 if usage == nil {
510 t.Fatal("expected a usage chunk")
511 }
512 if usage.PromptTokens != 25 || usage.CompletionTokens != 3 || usage.TotalTokens != 28 {
513 t.Fatalf("usage tokens = %+v", usage)
514 }
515 if usage.CacheHitTokens != 7 || usage.CacheMissTokens != 18 {
516 t.Fatalf("usage cache = hit %d miss %d", usage.CacheHitTokens, usage.CacheMissTokens)
517 }
518 if usage.CacheWriteTokens != 5 || usage.CacheWriteBilledTokens != 0 {
519 t.Fatalf("compatible-gateway cache write = raw %d billed %v, want 5/0", usage.CacheWriteTokens, usage.CacheWriteBilledTokens)
520 }
521 if usage.FinishReason != "stop" {
522 t.Fatalf("finish reason = %q", usage.FinishReason)
523 }
524 }
525
526 func TestReadStreamPricesNativeCacheWritesAtDefaultTTL(t *testing.T) {
527 sse := `event: message_start
528 data: {"type":"message_start","message":{"usage":{"input_tokens":3,"cache_creation_input_tokens":5,"cache_read_input_tokens":7,"output_tokens":0}}}
529
530 event: message_delta
531 data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}
532
533 event: message_stop
534 data: {"type":"message_stop"}
535 `
536 c := &client{name: "anthropic", nativeAnthropic: true}
537 resp := &http.Response{Body: io.NopCloser(strings.NewReader(sse))}
538 ch := make(chan provider.Chunk)
539 go c.readStream(context.Background(), resp, ch)
540
541 var usage *provider.Usage
542 for ck := range ch {
543 if ck.Type == provider.ChunkError {
544 t.Fatalf("unexpected error chunk: %v", ck.Err)
545 }
546 if ck.Type == provider.ChunkUsage {
547 usage = ck.Usage
548 }
549 }
550 if usage == nil {
551 t.Fatal("expected a usage chunk")
552 }
553 if usage.CacheWriteTokens != 5 || usage.CacheWriteBilledTokens != 6.25 {
554 t.Fatalf("usage cache write = raw %d billed %v, want 5/6.25", usage.CacheWriteTokens, usage.CacheWriteBilledTokens)
555 }
556 }
557
558 // TestReadStreamError surfaces a mid-stream error event as a ChunkError.
559 func TestReadStreamError(t *testing.T) {
560 sse := "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n"
561 c := &client{name: "anthropic"}
562 resp := &http.Response{Body: io.NopCloser(strings.NewReader(sse))}
563 ch := make(chan provider.Chunk)
564 go c.readStream(context.Background(), resp, ch)
565
566 var gotErr error
567 for ck := range ch {
568 if ck.Type == provider.ChunkError {
569 gotErr = ck.Err
570 }
571 }
572 if gotErr == nil || !strings.Contains(gotErr.Error(), "overloaded") {
573 t.Fatalf("expected an error chunk mentioning overloaded, got %v", gotErr)
574 }
575 }
576
577 // TestBuildRequestThinking checks that, with thinking enabled, the request carries
578 // the adaptive thinking + effort config and the prior assistant turn's signed
579 // thinking block is replayed first (before its tool_use).
580 func TestBuildRequestThinking(t *testing.T) {
581 c := &client{model: "claude-opus-4-8", thinking: "adaptive", effort: "high"}
582 r := c.buildRequest(context.Background(), provider.Request{
583 Messages: []provider.Message{
584 {Role: provider.RoleUser, Content: "weather?"},
585 {Role: provider.RoleAssistant, ReasoningContent: "Let me check.", ReasoningSignature: "sig-abc",
586 ToolCalls: []provider.ToolCall{{ID: "t1", Name: "get_weather", Arguments: `{"city":"Paris"}`}}},
587 {Role: provider.RoleTool, ToolCallID: "t1", Content: "sunny"},
588 },
589 })
590 if r.Thinking == nil || r.Thinking.Type != "adaptive" || r.Thinking.Display != "summarized" {
591 t.Fatalf("thinking config = %+v", r.Thinking)
592 }
593 if r.OutputConfig == nil || r.OutputConfig.Effort != "high" {
594 t.Fatalf("output config = %+v", r.OutputConfig)
595 }
596 asst := r.Messages[1]
597 if asst.Role != "assistant" || len(asst.Content) != 2 {
598 t.Fatalf("assistant msg = %+v", asst)
599 }
600 if asst.Content[0].Type != "thinking" || asst.Content[0].Thinking != "Let me check." || asst.Content[0].Signature != "sig-abc" {
601 t.Fatalf("first block should be the signed thinking block: %+v", asst.Content[0])
602 }
603 if asst.Content[1].Type != "tool_use" {
604 t.Fatalf("tool_use should follow the thinking block: %+v", asst.Content[1])
605 }
606 }
607
608 func TestBuildRequestOmitsResolvedToolCallMetadata(t *testing.T) {
609 readOnly := false
610 c := &client{model: "claude-opus-4-8"}
611 req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{
612 Role: provider.RoleAssistant,
613 ToolCalls: []provider.ToolCall{{
614 ID: "call_1", Name: "use_capability", Arguments: `{}`,
615 ResolvedName: "mcp__db__write", CapabilityID: "mcp-tool:db/write",
616 ResolvedReadOnly: &readOnly,
617 }},
618 }}})
619 b, err := json.Marshal(req.Messages)
620 if err != nil {
621 t.Fatalf("marshal: %v", err)
622 }
623 for _, forbidden := range []string{"resolved_name", "resolvedName", "capability_id", "capabilityId", "resolved_read_only", "resolvedReadOnly", "mcp__db__write"} {
624 if strings.Contains(string(b), forbidden) {
625 t.Fatalf("provider request leaked local tool metadata %q: %s", forbidden, b)
626 }
627 }
628 if !strings.Contains(string(b), `"name":"use_capability"`) {
629 t.Fatalf("provider request lost stable proxy name: %s", b)
630 }
631 }
632
633 func TestBuildRequestThinkingEnabledGateway(t *testing.T) {
634 c := &client{model: "LongCat-2.0", thinking: "enabled", effort: "disabled"}
635 r := c.buildRequest(context.Background(), provider.Request{
636 Messages: []provider.Message{
637 {Role: provider.RoleUser, Content: "hi"},
638 {Role: provider.RoleAssistant, Content: "ok", ReasoningContent: "signed reasoning", ReasoningSignature: "sig"},
639 },
640 })
641 if r.Thinking == nil || r.Thinking.Type != "disabled" || r.Thinking.Display != "" {
642 t.Fatalf("thinking config = %+v, want disabled without display", r.Thinking)
643 }
644 if r.OutputConfig != nil {
645 t.Fatalf("enabled/disabled gateway thinking must omit output_config: %+v", r.OutputConfig)
646 }
647 for _, block := range r.Messages[1].Content {
648 if block.Type == "thinking" {
649 t.Fatalf("enabled/disabled gateway must not replay Anthropic signed thinking blocks: %+v", r.Messages[1])
650 }
651 }
652 }
653
654 func TestBuildRequestDeepSeekThinking(t *testing.T) {
655 c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", effort: "max"}
656 r := c.buildRequest(context.Background(), provider.Request{
657 Messages: []provider.Message{
658 {Role: provider.RoleSystem, Content: "stable system"},
659 {Role: provider.RoleUser, Content: "weather?"},
660 {Role: provider.RoleAssistant, ReasoningContent: "I should call the tool.",
661 ToolCalls: []provider.ToolCall{{ID: "t1", Name: "get_weather", Arguments: `{"city":"Paris"}`}}},
662 {Role: provider.RoleTool, ToolCallID: "t1", Content: "sunny"},
663 },
664 Tools: []provider.ToolSchema{{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}},
665 })
666
667 if !provider.RequiresToolCallReasoning(c) || provider.RequiresReasoningRoundTrip(c) {
668 t.Fatal("DeepSeek thinking must preserve tool-call reasoning without retaining ordinary-turn reasoning")
669 }
670 if r.Thinking == nil || r.Thinking.Type != "enabled" || r.Thinking.Display != "" {
671 t.Fatalf("thinking config = %+v, want enabled without Anthropic display", r.Thinking)
672 }
673 if r.OutputConfig == nil || r.OutputConfig.Effort != "max" {
674 t.Fatalf("output_config = %+v, want max", r.OutputConfig)
675 }
676 asst := r.Messages[1]
677 if len(asst.Content) != 2 || asst.Content[0].Type != "thinking" || asst.Content[0].Thinking != "I should call the tool." || asst.Content[0].Signature != "" || asst.Content[1].Type != "tool_use" {
678 t.Fatalf("DeepSeek assistant blocks = %+v, want unsigned thinking before tool_use", asst.Content)
679 }
680 if r.System[0].CacheControl != nil || r.Tools[0].CacheControl != nil {
681 t.Fatal("DeepSeek ignores cache_control; system/tools must omit it")
682 }
683 for _, message := range r.Messages {
684 for _, block := range message.Content {
685 if block.CacheControl != nil {
686 t.Fatalf("DeepSeek message block unexpectedly carries cache_control: %+v", block)
687 }
688 }
689 }
690 }
691
692 func TestMissingToolCallReasoningWarningFingerprintTracksAnthropicConfiguration(t *testing.T) {
693 first := &client{name: "deepseek", baseURL: "https://api.deepseek.com/anthropic", model: "deepseek-v4-pro", deepseek: true, thinking: "enabled", effort: "high"}
694 same := &client{name: "deepseek", baseURL: "https://api.deepseek.com/anthropic", model: "deepseek-v4-pro", deepseek: true, thinking: "enabled", effort: "high"}
695 flash := &client{name: "deepseek", baseURL: "https://api.deepseek.com/anthropic", model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", effort: "high"}
696 got := provider.MissingToolCallReasoningWarningFingerprint(first)
697 if got != provider.MissingToolCallReasoningWarningFingerprint(same) {
698 t.Fatal("equivalent Anthropic configurations produced different fingerprints")
699 }
700 if got == provider.MissingToolCallReasoningWarningFingerprint(flash) {
701 t.Fatal("Anthropic model change did not re-key the warning fingerprint")
702 }
703 }
704
705 func TestBuildRequestDeepSeekThinkingModes(t *testing.T) {
706 for _, tc := range []struct {
707 name, model, input, want string
708 }{
709 {name: "Flash low", model: "deepseek-v4-flash", input: "low", want: "low"},
710 {name: "Flash legacy medium", model: "deepseek-v4-flash", input: "medium", want: "high"},
711 {name: "Flash legacy xhigh", model: "deepseek-v4-flash", input: "xhigh", want: "high"},
712 {name: "Pro low", model: "deepseek-v4-pro", input: "low", want: "low"},
713 {name: "Pro legacy medium", model: "deepseek-v4-pro", input: "medium", want: "high"},
714 {name: "Pro legacy xhigh", model: "deepseek-v4-pro", input: "xhigh", want: "high"},
715 {name: "Sonnet alias uses Flash", model: "claude-sonnet-4-6", input: "low", want: "low"},
716 {name: "Opus alias legacy xhigh", model: "claude-opus-4-8", input: "xhigh", want: "high"},
717 {name: "unknown model falls back to Flash", model: "unknown-model", input: "xhigh", want: "high"},
718 } {
719 t.Run(tc.name, func(t *testing.T) {
720 if tc.input != tc.want {
721 _, err := New(provider.Config{BaseURL: "https://api.deepseek.com/anthropic", Model: tc.model, Extra: map[string]any{"effort": tc.input}})
722 if err == nil {
723 t.Fatal("undeclared alias accepted")
724 }
725 return
726 }
727 r := (&client{model: tc.model, deepseek: true, effort: tc.input}).buildRequest(context.Background(), provider.Request{})
728 if r.Thinking == nil || r.Thinking.Type != "enabled" || r.OutputConfig == nil || r.OutputConfig.Effort != tc.want {
729 t.Fatalf("DeepSeek thinking = %+v / %+v, want enabled/%s", r.Thinking, r.OutputConfig, tc.want)
730 }
731 })
732 }
733 t.Run("provider default", func(t *testing.T) {
734 r := (&client{model: "deepseek-v4-flash", deepseek: true}).buildRequest(context.Background(), provider.Request{})
735 if r.Thinking == nil || r.Thinking.Type != "enabled" || r.OutputConfig != nil {
736 t.Fatalf("default DeepSeek thinking = %+v / %+v, want enabled/provider-default effort", r.Thinking, r.OutputConfig)
737 }
738 })
739
740 t.Run("disabled", func(t *testing.T) {
741 c := &client{model: "deepseek-v4-flash", deepseek: true, thinking: "enabled", effort: "disabled"}
742 r := c.buildRequest(context.Background(), provider.Request{
743 Messages: []provider.Message{{Role: provider.RoleAssistant, ReasoningContent: "replay anyway"}},
744 Tools: []provider.ToolSchema{{Name: "tool"}},
745 })
746 if r.Thinking == nil || r.Thinking.Type != "disabled" || r.OutputConfig != nil {
747 t.Fatalf("disabled DeepSeek thinking = %+v / %+v", r.Thinking, r.OutputConfig)
748 }
749 if provider.RequiresToolCallReasoning(c) || provider.RequiresReasoningRoundTrip(c) {
750 t.Fatal("disabled DeepSeek thinking must not retain reasoning for replay")
751 }
752 // Historical thinking blocks are replayed even when the current request
753 // disables thinking, the same rule tool-call turns already follow.
754 if len(r.Messages) != 1 || len(r.Messages[0].Content) != 1 || r.Messages[0].Content[0].Type != "thinking" ||
755 r.Messages[0].Content[0].Thinking != "replay anyway" {
756 t.Fatalf("reasoning-only assistant under disabled thinking = %+v, want the thinking block replayed", r.Messages)
757 }
758 })
759 }
760
761 func TestBuildRequestDeepSeekPreservesCallerTemperature(t *testing.T) {
762 zero := provider.TemperaturePtr(0)
763 r := (&client{model: "deepseek-v4-flash", deepseek: true}).buildRequest(context.Background(), provider.Request{Temperature: zero})
764 if r.Temperature == nil || *r.Temperature != 0 {
765 t.Fatalf("DeepSeek temperature = %v, want explicit zero", r.Temperature)
766 }
767 b, err := json.Marshal(r)
768 if err != nil {
769 t.Fatalf("marshal: %v", err)
770 }
771 if !strings.Contains(string(b), `"temperature":0`) {
772 t.Fatalf("DeepSeek request omitted explicit temperature: %s", b)
773 }
774
775 native := (&client{model: "claude-opus-4-8"}).buildRequest(context.Background(), provider.Request{Temperature: provider.TemperaturePtr(0.5)})
776 if native.Temperature != nil {
777 t.Fatalf("native Anthropic temperature = %v, want omitted", native.Temperature)
778 }
779 b, err = json.Marshal(native)
780 if err != nil {
781 t.Fatalf("marshal native: %v", err)
782 }
783 if strings.Contains(string(b), `"temperature"`) {
784 t.Fatalf("native Anthropic request must omit temperature: %s", b)
785 }
786 }
787
788 // TestBuildRequestThinkingOff is the default: no thinking field, and reasoning is
789 // NOT replayed (even with a signature present) since the model wasn't asked to think.
790 func TestBuildRequestThinkingOff(t *testing.T) {
791 c := &client{model: "claude-opus-4-8"}
792 r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{
793 {Role: provider.RoleUser, Content: "hi"},
794 {Role: provider.RoleAssistant, Content: "ok", ReasoningContent: "x", ReasoningSignature: "sig"},
795 }})
796 if r.Thinking != nil || r.OutputConfig != nil {
797 t.Fatalf("thinking should be off by default: %+v / %+v", r.Thinking, r.OutputConfig)
798 }
799 for _, b := range r.Messages[1].Content {
800 if b.Type == "thinking" {
801 t.Fatal("thinking block must not be replayed when thinking is off")
802 }
803 }
804 }
805
806 func TestBuildRequestDropsLocalMetadata(t *testing.T) {
807 c := &client{model: "claude-opus-4-8"}
808 r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{
809 {Role: provider.RoleUser, Content: "continue"},
810 {Role: provider.RoleUser, Content: "edited prompt", Edited: true, Original: "original prompt"},
811 {Role: provider.RoleAssistant, Content: "done", WorkDurationMs: 24_000, MemoryCitations: []provider.MemoryCitation{{
812 ID: "mem-1", Source: "MEMORY.md", LineStart: 116, LineEnd: 123, Note: "workflow",
813 }}},
814 }})
815 b, err := json.Marshal(r.Messages)
816 if err != nil {
817 t.Fatalf("marshal: %v", err)
818 }
819 if strings.Contains(string(b), "memoryCitations") || strings.Contains(string(b), "MEMORY.md") {
820 t.Fatalf("local memory citations leaked into Anthropic request: %s", b)
821 }
822 if strings.Contains(string(b), "workDurationMs") || strings.Contains(string(b), "work_duration_ms") {
823 t.Fatalf("local work duration leaked into Anthropic request: %s", b)
824 }
825 if strings.Contains(string(b), "original prompt") || strings.Contains(string(b), `"edited"`) || strings.Contains(string(b), `"original"`) {
826 t.Fatalf("local edit metadata leaked into Anthropic request: %s", b)
827 }
828 if !strings.Contains(string(b), "done") {
829 t.Fatalf("assistant content was dropped with local metadata: %s", b)
830 }
831 }
832
833 const sseThinking = `event: content_block_start
834 data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking"}}
835
836 event: content_block_delta
837 data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me "}}
838
839 event: content_block_delta
840 data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"think."}}
841
842 event: content_block_delta
843 data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG123"}}
844
845 event: content_block_stop
846 data: {"type":"content_block_stop","index":0}
847
848 event: content_block_delta
849 data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hi"}}
850
851 event: message_delta
852 data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":10}}
853
854 event: message_stop
855 data: {"type":"message_stop"}
856 `
857
858 // TestReadStreamThinking checks thinking_delta streams as reasoning text and
859 // signature_delta carries the signature back on a ChunkReasoning.
860 func TestReadStreamThinking(t *testing.T) {
861 c := &client{name: "anthropic"}
862 resp := &http.Response{Body: io.NopCloser(strings.NewReader(sseThinking))}
863 ch := make(chan provider.Chunk)
864 go c.readStream(context.Background(), resp, ch)
865
866 var reasoning, text strings.Builder
867 var sig string
868 for ck := range ch {
869 switch ck.Type {
870 case provider.ChunkReasoning:
871 reasoning.WriteString(ck.Text)
872 if ck.Signature != "" {
873 sig = ck.Signature
874 }
875 case provider.ChunkText:
876 text.WriteString(ck.Text)
877 }
878 }
879 if reasoning.String() != "Let me think." {
880 t.Fatalf("reasoning = %q", reasoning.String())
881 }
882 if sig != "SIG123" {
883 t.Fatalf("signature = %q", sig)
884 }
885 if text.String() != "Hi" {
886 t.Fatalf("text = %q", text.String())
887 }
888 }
889
890 // TestBaseURLNormalizedForV1Messages checks the URL-rewriting step in New().
891 // Anthropic's Messages endpoint is {root}/v1/messages, but the setup wizard
892 // accepts OpenAI-style URLs (e.g. "https://proxy.example.com/v1") because
893 // /models probes expect that shape. Without the strip, the chat client would
894 // concatenate /v1/messages onto an already-versioned root and the request
895 // would go to https://proxy.example.com/v1/v1/messages — failing 404.
896 func TestBaseURLNormalizedForV1Messages(t *testing.T) {
897 cases := []struct {
898 name string
899 in string
900 want string
901 }{
902 {"plain root (no /v1)", "https://api.anthropic.com", "https://api.anthropic.com"},
903 {"versioned v1 (OpenAI shape)", "https://proxy.example.com/v1", "https://proxy.example.com"},
904 {"versioned v1 with trailing slash", "https://proxy.example.com/v1/", "https://proxy.example.com"},
905 {"versioned v1 with path prefix", "https://gateway.example.com/api/v1", "https://gateway.example.com/api"},
906 {"trailing slash only", "https://api.anthropic.com/", "https://api.anthropic.com"},
907 {"empty falls back to default", "", "https://api.anthropic.com"},
908 }
909 for _, tc := range cases {
910 t.Run(tc.name, func(t *testing.T) {
911 p, err := New(provider.Config{
912 Name: "test",
913 Model: "claude-opus-4-8",
914 BaseURL: tc.in,
915 })
916 if err != nil {
917 t.Fatalf("New: %v", err)
918 }
919 c, ok := p.(*client)
920 if !ok {
921 t.Fatalf("provider type = %T, want *client", p)
922 }
923 if c.baseURL != tc.want {
924 t.Errorf("baseURL = %q, want %q", c.baseURL, tc.want)
925 }
926 })
927 }
928 }
929
930 func TestStreamSupportsBearerAuthHeaderAndCustomHeaders(t *testing.T) {
931 var gotAuth, gotAPIKey, gotVersion, gotUserAgent string
932 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
933 if r.URL.Path != "/v1/messages" {
934 t.Errorf("path = %q, want /v1/messages", r.URL.Path)
935 }
936 gotAuth = r.Header.Get("Authorization")
937 gotAPIKey = r.Header.Get("x-api-key")
938 gotVersion = r.Header.Get("anthropic-version")
939 gotUserAgent = r.Header.Get("User-Agent")
940 w.Header().Set("Content-Type", "text/event-stream")
941 _, _ = io.WriteString(w, `event: message_start
942 data: {"type":"message_start","message":{"usage":{"input_tokens":2,"output_tokens":0}}}
943
944 event: message_delta
945 data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}
946
947 event: message_stop
948 data: {"type":"message_stop"}
949
950 `)
951 }))
952 defer srv.Close()
953
954 p, err := New(provider.Config{
955 Name: "gateway",
956 BaseURL: srv.URL,
957 Model: "claude-sonnet-4-6",
958 APIKey: "sk-test",
959 Extra: map[string]any{
960 "auth_header": true,
961 "headers": map[string]string{
962 "User-Agent": "Reasonix",
963 "Authorization": "Bearer wrong",
964 "x-api-key": "wrong",
965 "anthropic-version": "bad",
966 },
967 },
968 })
969 if err != nil {
970 t.Fatalf("New: %v", err)
971 }
972 ch, err := p.Stream(context.Background(), provider.Request{
973 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
974 })
975 if err != nil {
976 t.Fatalf("Stream: %v", err)
977 }
978 var usage *provider.Usage
979 for chunk := range ch {
980 if chunk.Type == provider.ChunkUsage {
981 usage = chunk.Usage
982 }
983 }
984
985 if gotAuth != "Bearer sk-test" {
986 t.Fatalf("Authorization = %q, want Bearer sk-test", gotAuth)
987 }
988 if gotAPIKey != "" {
989 t.Fatalf("x-api-key = %q, want omitted", gotAPIKey)
990 }
991 if gotVersion != anthropicVersion {
992 t.Fatalf("anthropic-version = %q, want %q", gotVersion, anthropicVersion)
993 }
994 if gotUserAgent != "Reasonix" {
995 t.Fatalf("User-Agent = %q, want Reasonix", gotUserAgent)
996 }
997 if usage == nil || usage.RequestCount != 1 {
998 t.Fatalf("usage request count = %+v, want 1", usage)
999 }
1000 }
1001
1002 // Ensure the package wires into the registry under the expected kind.
1003
1003 lines GO