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