返回 DeepSeek-Reasonix
openai_test.go
根目录 / internal / provider / openai / openai_test.go
1 package openai
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 // TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the
17 // server returns 503 twice, then a valid SSE stream. The provider must back off,
18 // fire the retry-notify callback for each attempt, and ultimately stream the answer.
19 func TestStreamRetriesThenSucceeds(t *testing.T) {
20 var reqs int
21 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22 reqs++
23 if reqs <= 2 {
24 w.WriteHeader(http.StatusServiceUnavailable)
25 _, _ = w.Write([]byte(`{"error":"overloaded"}`))
26 return
27 }
28 w.Header().Set("Content-Type", "text/event-stream")
29 w.WriteHeader(http.StatusOK)
30 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi there\"}}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\ndata: [DONE]\n\n")
31 }))
32 defer srv.Close()
33
34 p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
35 if err != nil {
36 t.Fatalf("New: %v", err)
37 }
38
39 var attempts []int
40 ctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {
41 attempts = append(attempts, i.Attempt)
42 if i.Max != provider.MaxRetries {
43 t.Errorf("RetryInfo.Max = %d, want %d", i.Max, provider.MaxRetries)
44 }
45 })
46
47 ch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
48 if err != nil {
49 t.Fatalf("Stream after retries: %v", err)
50 }
51 var got strings.Builder
52 var usage *provider.Usage
53 for chunk := range ch {
54 if chunk.Type == provider.ChunkError {
55 t.Fatalf("unexpected stream error: %v", chunk.Err)
56 }
57 if chunk.Type == provider.ChunkText {
58 got.WriteString(chunk.Text)
59 }
60 if chunk.Type == provider.ChunkUsage {
61 usage = chunk.Usage
62 }
63 }
64 if got.String() != "hi there" {
65 t.Errorf("streamed text = %q, want %q", got.String(), "hi there")
66 }
67 if reqs != 3 {
68 t.Errorf("server saw %d requests, want 3 (2 failures + 1 success)", reqs)
69 }
70 if len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {
71 t.Errorf("retry-notify attempts = %v, want [1 2]", attempts)
72 }
73 if usage == nil || usage.RequestCount != 3 {
74 t.Errorf("usage request count = %+v, want 3", usage)
75 }
76 }
77
78 func TestMergeUsageCountsStreamsNotUsageChunks(t *testing.T) {
79 firstChunk := &provider.Usage{PromptTokens: 2, TotalTokens: 2, RequestCount: 2, CacheWriteTokens: 2, CacheWriteBilledTokens: 2.5}
80 secondChunk := &provider.Usage{CompletionTokens: 1, TotalTokens: 1, RequestCount: 2, CacheWriteTokens: 3, CacheWriteBilledTokens: 6}
81 oneStream := mergeUsage(firstChunk, secondChunk, false)
82 if oneStream.RequestCount != 2 {
83 t.Fatalf("same-stream request count = %d, want 2", oneStream.RequestCount)
84 }
85 if oneStream.CacheWriteTokens != 5 || oneStream.CacheWriteBilledTokens != 8.5 {
86 t.Fatalf("same-stream cache writes = raw %d billed %v, want 5/8.5", oneStream.CacheWriteTokens, oneStream.CacheWriteBilledTokens)
87 }
88 nextStream := &provider.Usage{PromptTokens: 3, TotalTokens: 3, RequestCount: 1}
89 combined := mergeUsage(oneStream, nextStream, true)
90 if combined.RequestCount != 3 {
91 t.Fatalf("multi-stream request count = %d, want 3", combined.RequestCount)
92 }
93 }
94
95 // TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed
96 // *provider.APIError carrying the status, so the display layer can explain it.
97 func TestStreamInsufficientBalance(t *testing.T) {
98 var reqs int
99 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 reqs++
101 w.WriteHeader(http.StatusPaymentRequired)
102 _, _ = w.Write([]byte(`{"error":"Insufficient Balance"}`))
103 }))
104 defer srv.Close()
105
106 p, _ := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
107 _, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
108 var apiErr *provider.APIError
109 if !errors.As(err, &apiErr) || apiErr.Status != 402 {
110 t.Fatalf("want *provider.APIError{Status:402}, got %T: %v", err, err)
111 }
112 if reqs != 1 {
113 t.Errorf("402 should not retry, server saw %d requests", reqs)
114 }
115 }
116
117 func TestStreamAnnotatesIndexedToolSchemaError(t *testing.T) {
118 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119 w.WriteHeader(http.StatusBadRequest)
120 _, _ = w.Write([]byte(`{"error":{"message":"Tool 1 function has invalid 'parameters' schema"}}`))
121 }))
122 defer srv.Close()
123
124 p, err := New(provider.Config{Name: "mimo", BaseURL: srv.URL, Model: "mimo-v2.5-pro", APIKey: "k"})
125 if err != nil {
126 t.Fatalf("New: %v", err)
127 }
128 _, err = p.Stream(context.Background(), provider.Request{
129 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
130 Tools: []provider.ToolSchema{
131 {Name: "read_file", Parameters: json.RawMessage(`{"type":"object"}`)},
132 {Name: "mcp__files__search", Parameters: json.RawMessage(`{"type":"object"}`)},
133 },
134 })
135 var apiErr *provider.APIError
136 if !errors.As(err, &apiErr) || !strings.Contains(apiErr.ToolContext, `MCP server "files"`) {
137 t.Fatalf("Stream error = %v, want MCP tool source context", err)
138 }
139 }
140
141 func TestBuildRequestScopesLegacyTupleMigrationToMiMo(t *testing.T) {
142 legacy := json.RawMessage(`{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"string"},{"type":"number"}]}}}`)
143 req := provider.Request{Tools: []provider.ToolSchema{{Name: "tuple", Parameters: legacy}}}
144
145 mimo := (&client{mimo: true}).buildRequest(req)
146 if got := string(mimo.Tools[0].Function.Parameters); !strings.Contains(got, `"prefixItems"`) || strings.Contains(got, `"items":[`) {
147 t.Fatalf("MiMo parameters = %s, want Draft 2020-12 tuple keywords", got)
148 }
149
150 other := (&client{}).buildRequest(req)
151 if got := string(other.Tools[0].Function.Parameters); got != string(legacy) {
152 t.Fatalf("non-MiMo parameters changed:\n got: %s\nwant: %s", got, legacy)
153 }
154 }
155
156 func TestBuildRequestOrdinaryDeepSeekBytesStayPrefixFree(t *testing.T) {
157 c := &client{model: "deepseek-v4-flash", deepseek: true, effort: "high"}
158 body, err := json.Marshal(c.buildRequest(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}))
159 if err != nil {
160 t.Fatalf("marshal request: %v", err)
161 }
162 want := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":true,"stream_options":{"include_usage":true},"reasoning_effort":"high","thinking":{"type":"enabled"}}`
163 if string(body) != want {
164 t.Fatalf("ordinary DeepSeek request bytes changed:\n got: %s\nwant: %s", body, want)
165 }
166 if strings.Contains(string(body), `"prefix"`) {
167 t.Fatalf("ordinary request leaked prefix mode: %s", body)
168 }
169 }
170
171 func TestBuildPrefixRequestAppendsWireOnlyAssistantTail(t *testing.T) {
172 c := &client{model: "deepseek-v4-pro", deepseek: true, effort: "high"}
173 req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "write a long answer"}}}
174 body, err := json.Marshal(c.buildPrefixRequest(req, "partial answer", "provider reasoning"))
175 if err != nil {
176 t.Fatalf("marshal prefix request: %v", err)
177 }
178 var decoded struct {
179 Messages []map[string]json.RawMessage `json:"messages"`
180 }
181 if err := json.Unmarshal(body, &decoded); err != nil {
182 t.Fatalf("decode prefix request: %v", err)
183 }
184 if len(decoded.Messages) != 2 {
185 t.Fatalf("messages = %d, want original user plus wire-only assistant prefix", len(decoded.Messages))
186 }
187 last := decoded.Messages[1]
188 if string(last["role"]) != `"assistant"` || string(last["content"]) != `"partial answer"` || string(last["prefix"]) != `true` {
189 t.Fatalf("prefix tail = %v, want assistant content with prefix=true", last)
190 }
191 if string(last["reasoning_content"]) != `"provider reasoning"` {
192 t.Fatalf("thinking prefix lost reasoning_content: %s", last)
193 }
194 if len(req.Messages) != 1 {
195 t.Fatal("buildPrefixRequest mutated the caller's persisted message slice")
196 }
197
198 disabled := &client{model: c.model, deepseek: true, effort: c.effort, thinkingType: "disabled"}
199 disabledBody, err := json.Marshal(disabled.buildPrefixRequest(req, "partial answer", "must stay local"))
200 if err != nil {
201 t.Fatalf("marshal disabled prefix request: %v", err)
202 }
203 if strings.Contains(string(disabledBody), "reasoning_content") || strings.Contains(string(disabledBody), "must stay local") {
204 t.Fatalf("non-thinking prefix must omit reasoning_content: %s", disabledBody)
205 }
206 }
207
208 func TestNewScopesPrefixContinuationToOfficialDeepSeekChatURL(t *testing.T) {
209 official, err := New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKey: "k"})
210 if err != nil {
211 t.Fatalf("New official DeepSeek: %v", err)
212 }
213 if got := official.(*client).prefixChatURL; got != "https://api.deepseek.com/beta/chat/completions" {
214 t.Fatalf("official prefix URL = %q", got)
215 }
216
217 gateway, err := New(provider.Config{
218 Name: "gateway", BaseURL: "https://gateway.example/v1", Model: "deepseek-v4-flash", APIKey: "k",
219 Extra: map[string]any{"reasoning_protocol": "deepseek"},
220 })
221 if err != nil {
222 t.Fatalf("New custom gateway: %v", err)
223 }
224 if got := gateway.(*client).prefixChatURL; got != "" {
225 t.Fatalf("custom gateway must not bypass itself for Beta continuation, got %q", got)
226 }
227 }
228
229 func TestStreamContinuesDeepSeekLengthWithAssistantPrefix(t *testing.T) {
230 var requests int
231 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232 requests++
233 body, _ := io.ReadAll(r.Body)
234 w.Header().Set("Content-Type", "text/event-stream")
235 switch r.URL.Path {
236 case "/chat/completions":
237 if strings.Contains(string(body), `"prefix":true`) {
238 t.Errorf("initial request unexpectedly enabled prefix mode: %s", body)
239 }
240 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think one. \"}}]}\n\n")
241 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"length\"}]}\n\n")
242 _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"total_tokens\":13,\"prompt_cache_hit_tokens\":8,\"prompt_cache_miss_tokens\":2,\"completion_tokens_details\":{\"reasoning_tokens\":1}}}\n\n")
243 _, _ = io.WriteString(w, "data: [DONE]\n\n")
244 case "/beta/chat/completions":
245 var decoded struct {
246 Messages []map[string]json.RawMessage `json:"messages"`
247 }
248 if err := json.Unmarshal(body, &decoded); err != nil {
249 t.Errorf("decode continuation request: %v", err)
250 http.Error(w, "invalid continuation request", http.StatusBadRequest)
251 return
252 }
253 last := decoded.Messages[len(decoded.Messages)-1]
254 if string(last["role"]) != `"assistant"` || string(last["content"]) != `"partial"` || string(last["prefix"]) != `true` {
255 t.Errorf("continuation tail = %s", last)
256 }
257 if string(last["reasoning_content"]) != `"think one. "` {
258 t.Errorf("continuation reasoning_content = %s", last["reasoning_content"])
259 }
260 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think two. \"}}]}\n\n")
261 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\" rest\"},\"finish_reason\":\"stop\"}]}\n\n")
262 _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":2,\"total_tokens\":22,\"prompt_cache_hit_tokens\":18,\"prompt_cache_miss_tokens\":2,\"completion_tokens_details\":{\"reasoning_tokens\":1}}}\n\n")
263 _, _ = io.WriteString(w, "data: [DONE]\n\n")
264 default:
265 http.NotFound(w, r)
266 }
267 }))
268 defer srv.Close()
269
270 c := &client{
271 name: "deepseek", apiKey: "k", baseURL: srv.URL, chatURL: srv.URL + "/chat/completions",
272 prefixChatURL: srv.URL + "/beta/chat/completions", model: "deepseek-v4-flash", deepseek: true,
273 effort: "high", http: srv.Client(), idleTimeout: defaultStreamIdleTimeout,
274 }
275 ch, err := c.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "write"}}})
276 if err != nil {
277 t.Fatalf("Stream: %v", err)
278 }
279 var text, reasoning strings.Builder
280 var usage *provider.Usage
281 usageChunks, doneChunks := 0, 0
282 for chunk := range ch {
283 switch chunk.Type {
284 case provider.ChunkText:
285 text.WriteString(chunk.Text)
286 case provider.ChunkReasoning:
287 reasoning.WriteString(chunk.Text)
288 case provider.ChunkUsage:
289 usageChunks++
290 usage = chunk.Usage
291 case provider.ChunkDone:
292 doneChunks++
293 case provider.ChunkError:
294 t.Fatalf("automatic continuation errored: %v", chunk.Err)
295 }
296 }
297 if requests != 2 || text.String() != "partial rest" || reasoning.String() != "think one. think two. " {
298 t.Fatalf("requests=%d text=%q reasoning=%q", requests, text.String(), reasoning.String())
299 }
300 if usageChunks != 1 || doneChunks != 1 || usage == nil {
301 t.Fatalf("usage chunks=%d done chunks=%d usage=%+v", usageChunks, doneChunks, usage)
302 }
303 if usage.PromptTokens != 30 || usage.CompletionTokens != 5 || usage.TotalTokens != 35 || usage.RequestCount != 2 ||
304 usage.CacheHitTokens != 26 || usage.CacheMissTokens != 4 || usage.ReasoningTokens != 2 || usage.FinishReason != "stop" {
305 t.Fatalf("merged usage = %+v", usage)
306 }
307 }
308
309 func TestStreamContinuesReasoningOnlyDeepSeekLength(t *testing.T) {
310 var requests int
311 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
312 requests++
313 body, _ := io.ReadAll(r.Body)
314 w.Header().Set("Content-Type", "text/event-stream")
315 switch r.URL.Path {
316 case "/chat/completions":
317 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think one. \"},\"finish_reason\":\"length\"}]}\n\n")
318 _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"total_tokens\":13,\"completion_tokens_details\":{\"reasoning_tokens\":3}}}\n\n")
319 _, _ = io.WriteString(w, "data: [DONE]\n\n")
320 case "/beta/chat/completions":
321 var decoded struct {
322 Messages []map[string]json.RawMessage `json:"messages"`
323 }
324 if err := json.Unmarshal(body, &decoded); err != nil {
325 t.Errorf("decode continuation request: %v", err)
326 http.Error(w, "invalid continuation request", http.StatusBadRequest)
327 return
328 }
329 last := decoded.Messages[len(decoded.Messages)-1]
330 if string(last["role"]) != `"assistant"` || string(last["content"]) != `""` || string(last["prefix"]) != `true` {
331 t.Errorf("reasoning-only continuation tail = %s", last)
332 }
333 if string(last["reasoning_content"]) != `"think one. "` {
334 t.Errorf("continuation reasoning_content = %s", last["reasoning_content"])
335 }
336 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think two. \"}}]}\n\n")
337 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"},\"finish_reason\":\"stop\"}]}\n\n")
338 _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":4,\"total_tokens\":24,\"completion_tokens_details\":{\"reasoning_tokens\":2}}}\n\n")
339 _, _ = io.WriteString(w, "data: [DONE]\n\n")
340 default:
341 http.NotFound(w, r)
342 }
343 }))
344 defer srv.Close()
345
346 c := &client{
347 name: "deepseek", apiKey: "k", baseURL: srv.URL, chatURL: srv.URL + "/chat/completions",
348 prefixChatURL: srv.URL + "/beta/chat/completions", model: "deepseek-v4-flash", deepseek: true,
349 effort: "high", http: srv.Client(), idleTimeout: defaultStreamIdleTimeout,
350 }
351 ch, err := c.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "write"}}})
352 if err != nil {
353 t.Fatalf("Stream: %v", err)
354 }
355 var text, reasoning strings.Builder
356 var usage *provider.Usage
357 for chunk := range ch {
358 switch chunk.Type {
359 case provider.ChunkText:
360 text.WriteString(chunk.Text)
361 case provider.ChunkReasoning:
362 reasoning.WriteString(chunk.Text)
363 case provider.ChunkUsage:
364 usage = chunk.Usage
365 case provider.ChunkError:
366 t.Fatalf("automatic reasoning-only continuation errored: %v", chunk.Err)
367 }
368 }
369 if requests != 2 || text.String() != "answer" || reasoning.String() != "think one. think two. " {
370 t.Fatalf("requests=%d text=%q reasoning=%q", requests, text.String(), reasoning.String())
371 }
372 if usage == nil || usage.PromptTokens != 30 || usage.CompletionTokens != 7 || usage.TotalTokens != 37 ||
373 usage.ReasoningTokens != 5 || usage.FinishReason != "stop" {
374 t.Fatalf("merged usage = %+v", usage)
375 }
376 }
377
378 func TestStreamKeepsTruncatedAnswerWhenDeepSeekBetaFails(t *testing.T) {
379 var requests int
380 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
381 requests++
382 if r.URL.Path == "/beta/chat/completions" {
383 http.Error(w, `{"error":{"message":"beta unavailable"}}`, http.StatusNotFound)
384 return
385 }
386 w.Header().Set("Content-Type", "text/event-stream")
387 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"keep me\"},\"finish_reason\":\"length\"}]}\n\n")
388 _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12}}\n\n")
389 _, _ = io.WriteString(w, "data: [DONE]\n\n")
390 }))
391 defer srv.Close()
392
393 c := &client{
394 name: "deepseek", apiKey: "k", baseURL: srv.URL, chatURL: srv.URL + "/chat/completions",
395 prefixChatURL: srv.URL + "/beta/chat/completions", model: "deepseek-v4-flash", deepseek: true,
396 effort: "high", http: srv.Client(), idleTimeout: defaultStreamIdleTimeout,
397 }
398 ch, err := c.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "write"}}})
399 if err != nil {
400 t.Fatalf("Stream: %v", err)
401 }
402 var text strings.Builder
403 var usage *provider.Usage
404 for chunk := range ch {
405 switch chunk.Type {
406 case provider.ChunkText:
407 text.WriteString(chunk.Text)
408 case provider.ChunkUsage:
409 usage = chunk.Usage
410 case provider.ChunkError:
411 t.Fatalf("Beta failure must fall back to the original answer, got %v", chunk.Err)
412 }
413 }
414 if requests != 2 || text.String() != "keep me" || usage == nil || usage.FinishReason != "length" {
415 t.Fatalf("requests=%d text=%q usage=%+v", requests, text.String(), usage)
416 }
417 }
418
419 func TestStreamBoundsRepeatedDeepSeekLengthContinuation(t *testing.T) {
420 var requests int
421 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422 requests++
423 w.Header().Set("Content-Type", "text/event-stream")
424 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"piece\"},\"finish_reason\":\"length\"}]}\n\n")
425 _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\n")
426 _, _ = io.WriteString(w, "data: [DONE]\n\n")
427 }))
428 defer srv.Close()
429
430 c := &client{
431 name: "deepseek", apiKey: "k", baseURL: srv.URL, chatURL: srv.URL + "/chat/completions",
432 prefixChatURL: srv.URL + "/beta/chat/completions", model: "deepseek-v4-flash", deepseek: true,
433 effort: "high", http: srv.Client(), idleTimeout: defaultStreamIdleTimeout,
434 }
435 ch, err := c.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "write"}}})
436 if err != nil {
437 t.Fatalf("Stream: %v", err)
438 }
439 var text strings.Builder
440 var usage *provider.Usage
441 for chunk := range ch {
442 if chunk.Type == provider.ChunkText {
443 text.WriteString(chunk.Text)
444 }
445 if chunk.Type == provider.ChunkUsage {
446 usage = chunk.Usage
447 }
448 if chunk.Type == provider.ChunkError {
449 t.Fatalf("unexpected stream error: %v", chunk.Err)
450 }
451 }
452 if requests != 2 || text.String() != "piecepiece" || usage == nil || usage.FinishReason != "length" {
453 t.Fatalf("requests=%d text=%q usage=%+v", requests, text.String(), usage)
454 }
455 }
456
457 func TestStreamDoesNotPrefixContinueToolCalls(t *testing.T) {
458 var requests int
459 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
460 requests++
461 w.Header().Set("Content-Type", "text/event-stream")
462 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{}"}}]}}]}`+"\n\n")
463 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`+"\n\n")
464 _, _ = io.WriteString(w, "data: [DONE]\n\n")
465 }))
466 defer srv.Close()
467
468 c := &client{
469 name: "deepseek", apiKey: "k", baseURL: srv.URL, chatURL: srv.URL + "/chat/completions",
470 prefixChatURL: srv.URL + "/beta/chat/completions", model: "deepseek-v4-flash", deepseek: true,
471 effort: "high", http: srv.Client(), idleTimeout: defaultStreamIdleTimeout,
472 }
473 ch, err := c.Stream(context.Background(), provider.Request{})
474 if err != nil {
475 t.Fatalf("Stream: %v", err)
476 }
477 toolCalls := 0
478 var usage *provider.Usage
479 for chunk := range ch {
480 if chunk.Type == provider.ChunkToolCall {
481 toolCalls++
482 }
483 if chunk.Type == provider.ChunkUsage {
484 usage = chunk.Usage
485 }
486 if chunk.Type == provider.ChunkError {
487 t.Fatalf("unexpected stream error: %v", chunk.Err)
488 }
489 }
490 if requests != 1 || toolCalls != 1 || usage == nil || usage.FinishReason != "length" {
491 t.Fatalf("requests=%d toolCalls=%d usage=%+v", requests, toolCalls, usage)
492 }
493 }
494
495 // TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError
496 // (naming the provider and its key env var) rather than a raw status body.
497 func TestStreamAuthError(t *testing.T) {
498 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
499 w.WriteHeader(http.StatusUnauthorized)
500 _, _ = w.Write([]byte(`{"error":{"message":"Authentication Fails, Your api key: ****ae54 is invalid"}}`))
501 }))
502 defer srv.Close()
503
504 p, err := New(provider.Config{
505 Name: "deepseek",
506 BaseURL: srv.URL,
507 Model: "deepseek-v4",
508 APIKey: "bad",
509 Extra: map[string]any{"api_key_env": "DEEPSEEK_API_KEY"},
510 })
511 if err != nil {
512 t.Fatalf("New: %v", err)
513 }
514
515 _, err = p.Stream(context.Background(), provider.Request{
516 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
517 })
518 var authErr *provider.AuthError
519 if !errors.As(err, &authErr) {
520 t.Fatalf("want *provider.AuthError, got %T: %v", err, err)
521 }
522 if authErr.Provider != "deepseek" || authErr.KeyEnv != "DEEPSEEK_API_KEY" || authErr.Status != 401 {
523 t.Errorf("AuthError fields wrong: %+v", authErr)
524 }
525 if msg := authErr.Error(); !strings.Contains(msg, "DEEPSEEK_API_KEY") || strings.Contains(msg, "ae54") {
526 t.Errorf("message should name the env var and not dump the raw body: %q", msg)
527 }
528 }
529
530 func TestStreamUsesConfiguredChatURL(t *testing.T) {
531 var sawRequest bool
532 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
533 sawRequest = true
534 if r.URL.Path != "/proxy/v1/chat/completions" {
535 t.Errorf("path = %s, want /proxy/v1/chat/completions", r.URL.Path)
536 http.NotFound(w, r)
537 return
538 }
539 if r.Header.Get("Authorization") != "Bearer k" {
540 http.Error(w, "bad key", http.StatusUnauthorized)
541 return
542 }
543 w.Header().Set("Content-Type", "text/event-stream")
544 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
545 }))
546 defer srv.Close()
547
548 p, err := New(provider.Config{
549 Name: "custom",
550 BaseURL: srv.URL + "/base",
551 Model: "model-a",
552 APIKey: "k",
553 Extra: map[string]any{"chat_url": srv.URL + "/proxy/v1/chat/completions"},
554 })
555 if err != nil {
556 t.Fatalf("New: %v", err)
557 }
558
559 ch, err := p.Stream(context.Background(), provider.Request{
560 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
561 })
562 if err != nil {
563 t.Fatalf("Stream: %v", err)
564 }
565 var got strings.Builder
566 for chunk := range ch {
567 if chunk.Type == provider.ChunkError {
568 t.Fatalf("stream error: %v", chunk.Err)
569 }
570 if chunk.Type == provider.ChunkText {
571 got.WriteString(chunk.Text)
572 }
573 }
574 if !sawRequest {
575 t.Fatal("server did not receive request")
576 }
577 if got.String() != "ok" {
578 t.Fatalf("streamed text = %q, want ok", got.String())
579 }
580 }
581
582 func TestStreamSendsCustomHeaders(t *testing.T) {
583 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
584 if r.Header.Get("Authorization") != "Bearer real-key" {
585 http.Error(w, "authorization was not preserved", http.StatusUnauthorized)
586 return
587 }
588 if r.Header.Get("HTTP-Referer") != "https://app.example" || r.Header.Get("X-Title") != "Reasonix" {
589 http.Error(w, "custom headers missing", http.StatusForbidden)
590 return
591 }
592 if r.Header.Get("Accept") != "text/event-stream" {
593 http.Error(w, "reserved Accept header was overwritten", http.StatusBadRequest)
594 return
595 }
596 w.Header().Set("Content-Type", "text/event-stream")
597 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
598 }))
599 defer srv.Close()
600
601 p, err := New(provider.Config{
602 Name: "custom",
603 BaseURL: srv.URL,
604 Model: "model-a",
605 APIKey: "real-key",
606 Extra: map[string]any{"headers": map[string]string{
607 "Authorization": "Bearer wrong",
608 "Accept": "application/json",
609 "HTTP-Referer": "https://app.example",
610 "X-Title": "Reasonix",
611 }},
612 })
613 if err != nil {
614 t.Fatalf("New: %v", err)
615 }
616
617 ch, err := p.Stream(context.Background(), provider.Request{
618 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
619 })
620 if err != nil {
621 t.Fatalf("Stream: %v", err)
622 }
623 for chunk := range ch {
624 if chunk.Type == provider.ChunkError {
625 t.Fatalf("stream error: %v", chunk.Err)
626 }
627 }
628 }
629
630 func TestStreamUsesMiMoAPIKeyHeader(t *testing.T) {
631 var gotAuth, gotAPIKey string
632 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
633 gotAuth = r.Header.Get("Authorization")
634 gotAPIKey = r.Header.Get("api-key")
635 w.Header().Set("Content-Type", "text/event-stream")
636 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
637 }))
638 defer srv.Close()
639
640 p, err := New(provider.Config{
641 Name: "mimo",
642 BaseURL: "https://api.xiaomimimo.com/v1",
643 Model: "mimo-v2.5-pro",
644 APIKey: "mimo-key",
645 })
646 if err != nil {
647 t.Fatalf("New: %v", err)
648 }
649 c := p.(*client)
650 if !c.mimo {
651 t.Fatal("official MiMo endpoint did not enable the Draft 2020-12 schema adapter")
652 }
653 c.chatURL = srv.URL
654
655 ch, err := p.Stream(context.Background(), provider.Request{
656 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
657 })
658 if err != nil {
659 t.Fatalf("Stream: %v", err)
660 }
661 for chunk := range ch {
662 if chunk.Type == provider.ChunkError {
663 t.Fatalf("stream error: %v", chunk.Err)
664 }
665 }
666 if gotAPIKey != "mimo-key" {
667 t.Fatalf("api-key = %q, want mimo-key", gotAPIKey)
668 }
669 if gotAuth != "" {
670 t.Fatalf("Authorization = %q, want omitted for MiMo", gotAuth)
671 }
672 }
673
674 func TestStreamSendsExtraBody(t *testing.T) {
675 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
676 body, err := io.ReadAll(r.Body)
677 if err != nil {
678 http.Error(w, "read body", http.StatusBadRequest)
679 return
680 }
681 var req map[string]any
682 if err := json.Unmarshal(body, &req); err != nil {
683 http.Error(w, "bad json", http.StatusBadRequest)
684 return
685 }
686 if req["enable_thinking"] != true {
687 http.Error(w, "extra enable_thinking missing", http.StatusBadRequest)
688 return
689 }
690 if got, ok := req["top_p"].(float64); !ok || got != 0.7 {
691 http.Error(w, "extra top_p missing", http.StatusBadRequest)
692 return
693 }
694 if req["model"] != "model-a" || req["stream"] != true {
695 http.Error(w, "reserved fields were overwritten", http.StatusBadRequest)
696 return
697 }
698 w.Header().Set("Content-Type", "text/event-stream")
699 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
700 }))
701 defer srv.Close()
702
703 p, err := New(provider.Config{
704 Name: "custom",
705 BaseURL: srv.URL,
706 Model: "model-a",
707 APIKey: "real-key",
708 Extra: map[string]any{"extra_body": map[string]any{
709 "enable_thinking": true,
710 "top_p": 0.7,
711 "model": "wrong",
712 "stream": false,
713 }},
714 })
715 if err != nil {
716 t.Fatalf("New: %v", err)
717 }
718
719 ch, err := p.Stream(context.Background(), provider.Request{
720 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
721 })
722 if err != nil {
723 t.Fatalf("Stream: %v", err)
724 }
725 for chunk := range ch {
726 if chunk.Type == provider.ChunkError {
727 t.Fatalf("stream error: %v", chunk.Err)
728 }
729 }
730 }
731
732 // TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:
733 // DeepSeek rejects a message missing the `content` field, so every message must
734 // serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,
735 // and accepted by DeepSeek — verified against a live multi-tool session); other
736 // roles serialize a string. The field must never be absent.
737 func TestBuildRequestAlwaysSerializesContent(t *testing.T) {
738 c := &client{model: "deepseek-v4"}
739 req := c.buildRequest(provider.Request{
740 Messages: []provider.Message{
741 {Role: provider.RoleUser, Content: "list the files"},
742 // Assistant turn with no text, only a tool call — the offending shape.
743 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
744 {ID: "call_1", Name: "ls", Arguments: `{"path":"."}`},
745 }},
746 {Role: provider.RoleTool, Content: "main.go", ToolCallID: "call_1", Name: "ls"},
747 },
748 })
749
750 b, err := json.Marshal(req.Messages)
751 if err != nil {
752 t.Fatalf("marshal: %v", err)
753 }
754 // Decode generically so we can assert the key's presence (not just its value).
755 var raw []map[string]json.RawMessage
756 if err := json.Unmarshal(b, &raw); err != nil {
757 t.Fatalf("unmarshal: %v", err)
758 }
759 for i, m := range raw {
760 if _, ok := m["content"]; !ok {
761 t.Errorf("messages[%d] is missing the content field: %s", i, b)
762 }
763 }
764 // The tool-call-only assistant message must carry content:null and its tool_calls.
765 if got := string(raw[1]["content"]); got != `null` {
766 t.Errorf("assistant content = %s, want null", got)
767 }
768 if _, ok := raw[1]["tool_calls"]; !ok {
769 t.Errorf("assistant message lost its tool_calls: %s", b)
770 }
771 }
772
773 func TestBuildRequestOmitsResolvedToolCallMetadata(t *testing.T) {
774 readOnly := false
775 c := &client{model: "deepseek-v4"}
776 req := c.buildRequest(provider.Request{Messages: []provider.Message{{
777 Role: provider.RoleAssistant,
778 ToolCalls: []provider.ToolCall{{
779 ID: "call_1", Name: "use_capability", Arguments: `{}`,
780 ResolvedName: "mcp__db__write", CapabilityID: "mcp-tool:db/write",
781 ResolvedReadOnly: &readOnly,
782 }},
783 }}})
784 b, err := json.Marshal(req.Messages)
785 if err != nil {
786 t.Fatalf("marshal: %v", err)
787 }
788 for _, forbidden := range []string{"resolved_name", "resolvedName", "capability_id", "capabilityId", "resolved_read_only", "resolvedReadOnly", "mcp__db__write"} {
789 if strings.Contains(string(b), forbidden) {
790 t.Fatalf("provider request leaked local tool metadata %q: %s", forbidden, b)
791 }
792 }
793 if !strings.Contains(string(b), `"name":"use_capability"`) {
794 t.Fatalf("provider request lost stable proxy name: %s", b)
795 }
796 }
797
798 // TestToolResultEmptyNameStillSerialized guards MiMo #4711: a strict
799 // OpenAI-compatible backend rejects a role=tool message whose `name` key is
800 // absent ("Param Incorrect, name is not set"). A legacy empty-name tool result
801 // must still carry the key (as an empty string) rather than vanish via
802 // omitempty.
803 func TestToolResultEmptyNameStillSerialized(t *testing.T) {
804 c := &client{model: "deepseek-v4"}
805 req := c.buildRequest(provider.Request{Messages: []provider.Message{
806 // Both the tool_call and its result have an empty name: the legacy
807 // #4727 shape where backfill has no source to recover from. The wire
808 // must still carry the name key so strict backends don't 400.
809 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "", Arguments: `{}`}}},
810 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "", Content: "file contents"},
811 }})
812 b, err := json.Marshal(req.Messages)
813 if err != nil {
814 t.Fatalf("marshal: %v", err)
815 }
816 // The tool result message must carry the name key even though the name is
817 // empty — strict backends 400 a missing key.
818 var msgs []map[string]any
819 if err := json.Unmarshal(b, &msgs); err != nil {
820 t.Fatalf("unmarshal: %v", err)
821 }
822 if len(msgs) != 2 {
823 t.Fatalf("messages = %d, want 2", len(msgs))
824 }
825 roles := []string{msgs[0]["role"].(string), msgs[1]["role"].(string)}
826 if roles[1] != "tool" {
827 t.Fatalf("second message role = %q, want tool", roles[1])
828 }
829 // Tool message: name key must be present (empty string serialized).
830 if _, ok := msgs[1]["name"]; !ok {
831 t.Fatalf("tool message lost its name key (must serialize empty): %s", b)
832 }
833 if name, _ := msgs[1]["name"].(string); name != "" {
834 t.Fatalf("tool message name = %q, want empty (legacy empty-name result)", name)
835 }
836 // Non-tool messages: name key must stay absent (byte-stable prefix).
837 for i, m := range msgs {
838 if roles[i] == "tool" {
839 continue
840 }
841 if _, ok := m["name"]; ok {
842 t.Fatalf("non-tool message %d leaked name key: %s", i, b)
843 }
844 }
845 }
846
847 // TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400
848 // "An assistant message with 'tool_calls' must be followed by tool messages
849 // responding to each 'tool_call_id'". A resumed/interrupted session can carry an
850 // assistant tool_calls turn whose tool results never landed; the server here
851 // mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the
852 // request must be repaired before it is sent.
853 func TestStreamRepairsDanglingToolCalls(t *testing.T) {
854 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
855 var req struct {
856 Messages []struct {
857 Role string `json:"role"`
858 ToolCalls []struct {
859 ID string `json:"id"`
860 } `json:"tool_calls"`
861 ToolCallID string `json:"tool_call_id"`
862 } `json:"messages"`
863 }
864 body, _ := io.ReadAll(r.Body)
865 _ = json.Unmarshal(body, &req)
866 answered := map[string]bool{}
867 for _, m := range req.Messages {
868 if m.Role == "tool" {
869 answered[m.ToolCallID] = true
870 }
871 }
872 for _, m := range req.Messages {
873 if m.Role != "assistant" {
874 continue
875 }
876 for _, tc := range m.ToolCalls {
877 if !answered[tc.ID] {
878 w.WriteHeader(http.StatusBadRequest)
879 _, _ = w.Write([]byte(`{"error":{"message":"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}`))
880 return
881 }
882 }
883 }
884 w.Header().Set("Content-Type", "text/event-stream")
885 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n")
886 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":1,\"total_tokens\":6}}\n\n")
887 _, _ = io.WriteString(w, "data: [DONE]\n\n")
888 }))
889 defer srv.Close()
890
891 p, err := New(provider.Config{Name: "deepseek-flash", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
892 if err != nil {
893 t.Fatalf("New: %v", err)
894 }
895
896 // An assistant tool_calls turn whose tool result never landed (an interrupted
897 // turn), followed by a fresh user message — the exact shape that 400s.
898 ch, err := p.Stream(context.Background(), provider.Request{
899 Messages: []provider.Message{
900 {Role: provider.RoleUser, Content: "list the files"},
901 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
902 {ID: "call_1", Name: "ls", Arguments: `{"path":"."}`},
903 }},
904 {Role: provider.RoleUser, Content: "never mind, what time is it?"},
905 },
906 })
907 if err != nil {
908 t.Fatalf("Stream sent a dangling tool_calls to the API: %v", err)
909 }
910 var streamErr error
911 var text strings.Builder
912 for chunk := range ch {
913 switch chunk.Type {
914 case provider.ChunkText:
915 text.WriteString(chunk.Text)
916 case provider.ChunkError:
917 streamErr = chunk.Err
918 }
919 }
920 if streamErr != nil {
921 t.Fatalf("stream errored: %v", streamErr)
922 }
923 if text.String() != "done" {
924 t.Fatalf("completion text = %q, want \"done\"", text.String())
925 }
926 }
927
928 // TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.
929 func TestNormaliseUsageDeepSeekShape(t *testing.T) {
930 u := normaliseUsage(&wireUsage{
931 PromptTokens: 1000,
932 CompletionTokens: 200,
933 TotalTokens: 1200,
934 PromptCacheHitTokens: 900,
935 PromptCacheMissTokens: 100,
936 })
937 if u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {
938 t.Errorf("DeepSeek-shape cache fields lost: hit=%d miss=%d", u.CacheHitTokens, u.CacheMissTokens)
939 }
940 }
941
942 // TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /
943 // completion_tokens_details path used by OpenAI and MiMo. Miss is derived
944 // from prompt - hit when only hit is provided.
945 func TestNormaliseUsageMiMoShape(t *testing.T) {
946 u := normaliseUsage(&wireUsage{
947 PromptTokens: 1000,
948 CompletionTokens: 500,
949 TotalTokens: 1500,
950 PromptTokensDetails: &struct {
951 CachedTokens int `json:"cached_tokens"`
952 }{CachedTokens: 600},
953 CompletionTokensDetails: &struct {
954 ReasoningTokens int `json:"reasoning_tokens"`
955 }{ReasoningTokens: 180},
956 })
957 if u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {
958 t.Errorf("nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)", u.CacheHitTokens, u.CacheMissTokens)
959 }
960 if u.ReasoningTokens != 180 {
961 t.Errorf("reasoning tokens lost: %d", u.ReasoningTokens)
962 }
963 }
964
965 // TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant
966 // turn's reasoning_content is a response-only signal and must never be echoed
967 // back in the outgoing request. DeepSeek otherwise counts it as paid prompt
968 // input (~500 tok/turn on a reasoner chain). The session keeps it for
969 // display/archive; the wire request must not carry it.
970 func TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {
971 c := &client{model: "deepseek-reasoner", deepseek: true}
972 req := c.buildRequest(provider.Request{
973 Messages: []provider.Message{
974 {Role: provider.RoleUser, Content: "explain"},
975 {Role: provider.RoleAssistant, Content: "the answer", ReasoningContent: "SECRET-CHAIN-OF-THOUGHT"},
976 {Role: provider.RoleUser, Content: "thanks"},
977 },
978 })
979 b, err := json.Marshal(req.Messages)
980 if err != nil {
981 t.Fatalf("marshal: %v", err)
982 }
983 if strings.Contains(string(b), "reasoning_content") {
984 t.Errorf("a no-tool-calls assistant turn must not carry reasoning_content: %s", b)
985 }
986 if strings.Contains(string(b), "SECRET-CHAIN-OF-THOUGHT") {
987 t.Errorf("the assistant chain-of-thought leaked into the request: %s", b)
988 }
989 if !strings.Contains(string(b), "the answer") {
990 t.Errorf("assistant content was dropped along with reasoning: %s", b)
991 }
992 }
993
994 func TestBuildRequestDropsLocalMetadata(t *testing.T) {
995 c := &client{model: "deepseek-chat", deepseek: true}
996 req := c.buildRequest(provider.Request{
997 Messages: []provider.Message{
998 {Role: provider.RoleUser, Content: "continue"},
999 {Role: provider.RoleUser, Content: "edited prompt", Edited: true, Original: "original prompt"},
1000 {Role: provider.RoleAssistant, Content: "done", WorkDurationMs: 24_000, MemoryCitations: []provider.MemoryCitation{{
1001 ID: "mem-1", Source: "MEMORY.md", LineStart: 116, LineEnd: 123, Note: "workflow",
1002 }}},
1003 },
1004 })
1005 b, err := json.Marshal(req.Messages)
1006 if err != nil {
1007 t.Fatalf("marshal: %v", err)
1008 }
1009 if strings.Contains(string(b), "memoryCitations") || strings.Contains(string(b), "MEMORY.md") {
1010 t.Fatalf("local memory citations leaked into OpenAI-compatible request: %s", b)
1011 }
1012 if strings.Contains(string(b), "workDurationMs") || strings.Contains(string(b), "work_duration_ms") {
1013 t.Fatalf("local work duration leaked into OpenAI-compatible request: %s", b)
1014 }
1015 if strings.Contains(string(b), "original prompt") || strings.Contains(string(b), `"edited"`) || strings.Contains(string(b), `"original"`) {
1016 t.Fatalf("local edit metadata leaked into OpenAI-compatible request: %s", b)
1017 }
1018 if !strings.Contains(string(b), "done") {
1019 t.Fatalf("assistant content was dropped with local metadata: %s", b)
1020 }
1021 }
1022
1023 // DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was
1024 // dropped on a cache-miss replay, so it must be round-tripped — but only on the
1025 // turn that carries tool calls, and only for the DeepSeek protocol.
1026 func TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {
1027 msgs := []provider.Message{
1028 {Role: provider.RoleUser, Content: "count the go files"},
1029 {
1030 Role: provider.RoleAssistant,
1031 ReasoningContent: "CHAIN-OF-THOUGHT",
1032 ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{"command":"ls"}`}},
1033 },
1034 {Role: provider.RoleTool, Content: "14", ToolCallID: "c1", Name: "bash"},
1035 }
1036 deepseek, _ := json.Marshal((&client{model: "deepseek-v4", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)
1037 if !strings.Contains(string(deepseek), "reasoning_content") || !strings.Contains(string(deepseek), "CHAIN-OF-THOUGHT") {
1038 t.Errorf("DeepSeek tool_calls turn must round-trip reasoning_content: %s", deepseek)
1039 }
1040
1041 other, _ := json.Marshal((&client{model: "mimo-v2"}).buildRequest(provider.Request{Messages: msgs}).Messages)
1042 if strings.Contains(string(other), "CHAIN-OF-THOUGHT") {
1043 t.Errorf("non-DeepSeek backends must not re-upload reasoning_content: %s", other)
1044 }
1045 }
1046
1047 func TestBuildRequestForwardsReasoningEffort(t *testing.T) {
1048 c := &client{model: "mimo-v2", effort: "high"}
1049 if got := c.buildRequest(provider.Request{}).ReasoningEffort; got != "high" {
1050 t.Errorf("ReasoningEffort = %q, want high", got)
1051 }
1052
1053 b, err := json.Marshal((&client{model: "deepseek-v4"}).buildRequest(provider.Request{}))
1054 if err != nil {
1055 t.Fatalf("marshal: %v", err)
1056 }
1057 if strings.Contains(string(b), "reasoning_effort") {
1058 t.Errorf("empty effort must be omitted from the payload: %s", b)
1059 }
1060 }
1061
1062 func TestNewDeepSeekV4FlashForwardsLowEffort(t *testing.T) {
1063 p, err := New(provider.Config{
1064 Name: "deepseek",
1065 BaseURL: "https://api.deepseek.com",
1066 Model: "deepseek-v4-flash",
1067 APIKey: "test",
1068 Extra: map[string]any{
1069 "effort": "low",
1070 "reasoning_protocol": "deepseek",
1071 },
1072 })
1073 if err != nil {
1074 t.Fatalf("New Flash low: %v", err)
1075 }
1076 if got := p.(*client).buildRequest(provider.Request{}).ReasoningEffort; got != "low" {
1077 t.Fatalf("Flash reasoning_effort = %q, want low", got)
1078 }
1079
1080 _, err = New(provider.Config{
1081 Name: "deepseek",
1082 BaseURL: "https://api.deepseek.com",
1083 Model: "deepseek-v4-pro",
1084 APIKey: "test",
1085 Extra: map[string]any{
1086 "effort": "low",
1087 "reasoning_protocol": "deepseek",
1088 },
1089 })
1090 if err == nil || !strings.Contains(err.Error(), "requires deepseek-v4-flash") {
1091 t.Fatalf("New Pro low error = %v, want model-scoped rejection", err)
1092 }
1093
1094 custom, err := New(provider.Config{
1095 Name: "custom-deepseek",
1096 BaseURL: "https://gateway.example.com/v1",
1097 Model: "custom-flash",
1098 APIKey: "test",
1099 Extra: map[string]any{
1100 "effort": "low",
1101 "reasoning_protocol": "deepseek",
1102 "supported_efforts": []string{"low", "high", "max"},
1103 },
1104 })
1105 if err != nil {
1106 t.Fatalf("New explicit custom low: %v", err)
1107 }
1108 if got := custom.(*client).buildRequest(provider.Request{}).ReasoningEffort; got != "low" {
1109 t.Fatalf("custom reasoning_effort = %q, want explicit low", got)
1110 }
1111 }
1112
1113 func TestBuildRequestTemperatureSerialization(t *testing.T) {
1114 c := &client{model: "m"}
1115
1116 omitted := c.buildRequest(provider.Request{})
1117 if omitted.Temperature != nil {
1118 t.Fatalf("unset request temperature = %v, want nil", omitted.Temperature)
1119 }
1120 b, err := json.Marshal(omitted)
1121 if err != nil {
1122 t.Fatalf("marshal omitted: %v", err)
1123 }
1124 if strings.Contains(string(b), "temperature") {
1125 t.Fatalf("unset temperature must be omitted from payload: %s", b)
1126 }
1127
1128 zero := c.buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0)})
1129 if zero.Temperature == nil || *zero.Temperature != 0 {
1130 t.Fatalf("zero request temperature = %v, want ptr(0)", zero.Temperature)
1131 }
1132 b, err = json.Marshal(zero)
1133 if err != nil {
1134 t.Fatalf("marshal zero: %v", err)
1135 }
1136 if !strings.Contains(string(b), `"temperature":0`) {
1137 t.Fatalf("explicit zero temperature must be serialized: %s", b)
1138 }
1139
1140 nonzero := c.buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0.25)})
1141 if nonzero.Temperature == nil || *nonzero.Temperature != 0.25 {
1142 t.Fatalf("nonzero request temperature = %v, want ptr(0.25)", nonzero.Temperature)
1143 }
1144 }
1145
1146 func TestBuildRequestKimiK3OfficialWireShape(t *testing.T) {
1147 p, err := New(provider.Config{
1148 Name: "kimi-cn",
1149 BaseURL: "https://api.moonshot.cn/v1",
1150 Model: "kimi-k3",
1151 APIKey: "k",
1152 Extra: map[string]any{
1153 "effort": "max",
1154 "supported_efforts": []string{"low", "high", "max"},
1155 "reasoning_protocol": "openai",
1156 "extra_body": map[string]any{
1157 "top_p": 0.5,
1158 "n": 2,
1159 "presence_penalty": 1,
1160 "frequency_penalty": 1,
1161 "max_completion_tokens": 99,
1162 "trace_id": "keep-me",
1163 },
1164 },
1165 })
1166 if err != nil {
1167 t.Fatalf("New: %v", err)
1168 }
1169 if !provider.RequiresReasoningRoundTrip(p) {
1170 t.Fatal("official Kimi K3 must retain raw reasoning for complete assistant-message replay")
1171 }
1172 req := p.(*client).buildRequest(provider.Request{
1173 Temperature: provider.TemperaturePtr(0),
1174 MaxTokens: 2000,
1175 Messages: []provider.Message{
1176 {Role: provider.RoleUser, Content: "first"},
1177 {Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "provider reasoning"},
1178 {Role: provider.RoleUser, Content: "use a tool"},
1179 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "lookup", Arguments: `{}`}}},
1180 {Role: provider.RoleTool, ToolCallID: "call-1", Name: "lookup", Content: "result"},
1181 },
1182 })
1183 if req.Temperature != nil || req.MaxTokens != 0 || req.MaxCompletionTokens != 2000 {
1184 t.Fatalf("Kimi K3 request limits = temperature %v, max_tokens %d, max_completion_tokens %d", req.Temperature, req.MaxTokens, req.MaxCompletionTokens)
1185 }
1186 if req.ReasoningEffort != "max" {
1187 t.Fatalf("reasoning_effort = %q, want max", req.ReasoningEffort)
1188 }
1189 if got := req.Messages[1].ReasoningContent; got == nil || *got != "provider reasoning" {
1190 t.Fatalf("plain assistant reasoning_content = %v, want provider reasoning", got)
1191 }
1192 if got := req.Messages[3].ReasoningContent; got == nil || *got != "" {
1193 t.Fatalf("tool-call assistant reasoning_content = %v, want explicit empty string", got)
1194 }
1195 body, err := json.Marshal(req)
1196 if err != nil {
1197 t.Fatalf("marshal: %v", err)
1198 }
1199 var wire map[string]any
1200 if err := json.Unmarshal(body, &wire); err != nil {
1201 t.Fatalf("unmarshal: %v", err)
1202 }
1203 for _, field := range []string{"temperature", "max_tokens", "top_p", "n", "presence_penalty", "frequency_penalty"} {
1204 if _, ok := wire[field]; ok {
1205 t.Fatalf("official Kimi K3 payload must omit %q: %s", field, body)
1206 }
1207 }
1208 if wire["max_completion_tokens"] != float64(2000) || wire["trace_id"] != "keep-me" {
1209 t.Fatalf("Kimi K3 payload lost output budget or unrelated extra body: %s", body)
1210 }
1211
1212 gateway, err := New(provider.Config{
1213 Name: "opencode-go",
1214 BaseURL: "https://opencode.ai/zen/go/v1",
1215 Model: "kimi-k3",
1216 Extra: map[string]any{
1217 "effort": "max",
1218 "supported_efforts": []string{"high", "max"},
1219 },
1220 })
1221 if err != nil {
1222 t.Fatalf("New gateway: %v", err)
1223 }
1224 if provider.RequiresReasoningRoundTrip(gateway) {
1225 t.Fatal("Kimi-specific wire policy must not be inferred for a relay")
1226 }
1227 gatewayReq := gateway.(*client).buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0), MaxTokens: 77})
1228 if gatewayReq.Temperature == nil || gatewayReq.MaxTokens != 77 || gatewayReq.MaxCompletionTokens != 0 {
1229 t.Fatalf("relay request was changed by official Kimi compatibility: %+v", gatewayReq)
1230 }
1231 }
1232
1233 func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) {
1234 newClient := func(t *testing.T, baseURL, model string, maxOutputTokens int) *client {
1235 t.Helper()
1236 p, err := New(provider.Config{
1237 Name: "test", BaseURL: baseURL, Model: model,
1238 Extra: map[string]any{"max_output_tokens": maxOutputTokens},
1239 })
1240 if err != nil {
1241 t.Fatalf("New: %v", err)
1242 }
1243 return p.(*client)
1244 }
1245
1246 deepseek := newClient(t, "https://api.deepseek.com", "deepseek-v4-flash", 0).buildRequest(provider.Request{})
1247 if deepseek.MaxTokens != provider.DefaultReasoningOutputTokens || deepseek.MaxCompletionTokens != 0 {
1248 t.Fatalf("DeepSeek output budget = max_tokens %d, max_completion_tokens %d", deepseek.MaxTokens, deepseek.MaxCompletionTokens)
1249 }
1250
1251 thinkingDisabledProvider, err := New(provider.Config{
1252 Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro",
1253 Extra: map[string]any{"thinking": "disabled", "max_output_tokens": 0},
1254 })
1255 if err != nil {
1256 t.Fatalf("New thinking-disabled DeepSeek: %v", err)
1257 }
1258 thinkingDisabled := thinkingDisabledProvider.(*client).buildRequest(provider.Request{})
1259 if thinkingDisabled.MaxTokens != 0 || thinkingDisabled.MaxCompletionTokens != 0 {
1260 t.Fatalf("thinking-disabled DeepSeek received an automatic output budget: %+v", thinkingDisabled)
1261 }
1262 effortDisabledProvider, err := New(provider.Config{
1263 Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro",
1264 Extra: map[string]any{"effort": "disabled", "max_output_tokens": 0},
1265 })
1266 if err != nil {
1267 t.Fatalf("New effort-disabled DeepSeek: %v", err)
1268 }
1269 effortDisabled := effortDisabledProvider.(*client).buildRequest(provider.Request{})
1270 if effortDisabled.MaxTokens != 0 || effortDisabled.Thinking == nil || effortDisabled.Thinking.Type != "disabled" {
1271 t.Fatalf("effort-disabled DeepSeek request = %+v, want thinking disabled without an automatic budget", effortDisabled)
1272 }
1273
1274 explicitDisabledProvider, err := New(provider.Config{
1275 Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro",
1276 Extra: map[string]any{"thinking": "disabled", "max_output_tokens": 8192},
1277 })
1278 if err != nil {
1279 t.Fatalf("New explicitly capped DeepSeek: %v", err)
1280 }
1281 explicitDisabled := explicitDisabledProvider.(*client).buildRequest(provider.Request{})
1282 if explicitDisabled.MaxTokens != 8192 {
1283 t.Fatalf("explicit thinking-disabled DeepSeek budget = %d, want 8192", explicitDisabled.MaxTokens)
1284 }
1285
1286 disabledDeepSeek := newClient(t, "https://api.deepseek.com", "deepseek-v4-flash", -1).buildRequest(provider.Request{})
1287 if disabledDeepSeek.MaxTokens != 0 || disabledDeepSeek.MaxCompletionTokens != 0 {
1288 t.Fatalf("disabled DeepSeek output budget = %+v", disabledDeepSeek)
1289 }
1290
1291 officialOpenAI := newClient(t, "https://api.openai.com/v1", "o3", 8192).buildRequest(provider.Request{})
1292 if officialOpenAI.MaxTokens != 0 || officialOpenAI.MaxCompletionTokens != 8192 {
1293 t.Fatalf("official OpenAI output budget = max_tokens %d, max_completion_tokens %d", officialOpenAI.MaxTokens, officialOpenAI.MaxCompletionTokens)
1294 }
1295
1296 gateway := newClient(t, "https://gateway.example/v1", "plain-chat", 8192).buildRequest(provider.Request{})
1297 if gateway.MaxTokens != 8192 || gateway.MaxCompletionTokens != 0 {
1298 t.Fatalf("compatible gateway output budget = max_tokens %d, max_completion_tokens %d", gateway.MaxTokens, gateway.MaxCompletionTokens)
1299 }
1300
1301 unspecifiedGateway := newClient(t, "https://gateway.example/v1", "plain-chat", 0).buildRequest(provider.Request{})
1302 if unspecifiedGateway.MaxTokens != 0 || unspecifiedGateway.MaxCompletionTokens != 0 {
1303 t.Fatalf("unspecified compatible gateway received a budget: %+v", unspecifiedGateway)
1304 }
1305 }
1306
1307 func TestBuildRequestDeepSeekThinking(t *testing.T) {
1308 for _, tc := range []struct {
1309 name string
1310 effort string
1311 wantThinking string
1312 wantReasoning string
1313 }{
1314 {name: "high", effort: "high", wantThinking: "enabled", wantReasoning: "high"},
1315 {name: "max", effort: "max", wantThinking: "enabled", wantReasoning: "max"},
1316 } {
1317 t.Run(tc.name, func(t *testing.T) {
1318 req := (&client{model: "deepseek-v4", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})
1319 if req.Thinking == nil || req.Thinking.Type != tc.wantThinking {
1320 t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantThinking)
1321 }
1322 if req.ReasoningEffort != tc.wantReasoning {
1323 t.Fatalf("ReasoningEffort = %q, want %q", req.ReasoningEffort, tc.wantReasoning)
1324 }
1325 })
1326 }
1327 }
1328
1329 func TestBuildRequestDeepSeekPreservesCallerTemperature(t *testing.T) {
1330 c := &client{model: "deepseek-v4", deepseek: true, effort: "high"}
1331
1332 omitted := c.buildRequest(provider.Request{})
1333 if omitted.Temperature != nil {
1334 t.Fatalf("DeepSeek default temperature = %v, want omitted", omitted.Temperature)
1335 }
1336
1337 zero := c.buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0)})
1338 if zero.Temperature == nil || *zero.Temperature != 0 {
1339 t.Fatalf("DeepSeek explicit zero temperature = %v, want ptr(0)", zero.Temperature)
1340 }
1341 if zero.Thinking == nil || zero.Thinking.Type != "enabled" {
1342 t.Fatalf("DeepSeek thinking = %+v, want enabled", zero.Thinking)
1343 }
1344 }
1345
1346 // TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is
1347 // the only knob (no reasoning_effort), and the empty-effort / auto case still
1348 // emits an explicit "adaptive" because that's what the M3 model default means
1349 // (M3 has no implicit "no thinking" mode at the wire level).
1350 func TestBuildRequestMiniMaxThinking(t *testing.T) {
1351 for _, tc := range []struct {
1352 name string
1353 effort string
1354 wantThinking string
1355 }{
1356 {name: "auto-defaults-to-adaptive", effort: "", wantThinking: "adaptive"},
1357 {name: "adaptive", effort: "adaptive", wantThinking: "adaptive"},
1358 {name: "disabled", effort: "disabled", wantThinking: "disabled"},
1359 } {
1360 t.Run(tc.name, func(t *testing.T) {
1361 req := (&client{model: "MiniMax-M3", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})
1362 if req.Thinking == nil || req.Thinking.Type != tc.wantThinking {
1363 t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantThinking)
1364 }
1365 if req.ReasoningEffort != "" {
1366 t.Fatalf("MiniMax must not send reasoning_effort, got %q", req.ReasoningEffort)
1367 }
1368 })
1369 }
1370 }
1371
1372 // TestNewMiniMaxEffortValidation locks in the boot-time validation for the
1373 // MiniMax path. The config effort layer remaps legacy level names, so by the
1374 // time effort reaches this factory it must be one of: "", "adaptive",
1375 // "disabled". Anything else is a config bug, surfaced now (not at request
1376 // time) for an actionable error.
1377 func TestNewMiniMaxEffortValidation(t *testing.T) {
1378 base := provider.Config{Name: "m3", BaseURL: "https://api.minimaxi.com/v1", Model: "MiniMax-M3", APIKey: "k"}
1379 // happy path: auto (empty effort) and both explicit values are accepted
1380 for _, ok := range []string{"", "adaptive", "disabled"} {
1381 if _, err := New(withEffort(base, ok)); err != nil {
1382 t.Errorf("effort=%q should be accepted: %v", ok, err)
1383 }
1384 }
1385 // unhappy: anything else is rejected up front
1386 for _, bad := range []string{"high", "low", "max", "turbo"} {
1387 if _, err := New(withEffort(base, bad)); err == nil {
1388 t.Errorf("effort=%q should be rejected", bad)
1389 }
1390 }
1391 }
1392
1393 // TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory
1394 // must set the `minimax` flag when the base URL points at api.minimaxi.com
1395 // (with or without the /v1 suffix) so buildRequest picks the right wire shape.
1396 func TestNewMiniMaxSetsFlag(t *testing.T) {
1397 for _, baseURL := range []string{
1398 "https://api.minimaxi.com/v1",
1399 "https://api.minimaxi.com",
1400 } {
1401 p, err := New(provider.Config{Name: "m3", BaseURL: baseURL, Model: "MiniMax-M3", APIKey: "k"})
1402 if err != nil {
1403 t.Fatalf("New(%q): %v", baseURL, err)
1404 }
1405 c := p.(*client)
1406 if !c.minimax {
1407 t.Errorf("minimax flag not set for baseURL=%q", baseURL)
1408 }
1409 }
1410 }
1411
1412 // TestBuildRequestZhipuThinking covers the Zhipu GLM wire shape: thinking.type
1413 // is enabled|disabled and reasoning_effort is never sent (the endpoint ignores
1414 // it). Auto (empty effort) defaults to "enabled" — the GLM model default.
1415 func TestBuildRequestZhipuThinking(t *testing.T) {
1416 for _, tc := range []struct {
1417 name string
1418 effort string
1419 wantThinking string
1420 }{
1421 {name: "auto-defaults-to-enabled", effort: "", wantThinking: "enabled"},
1422 {name: "enabled", effort: "enabled", wantThinking: "enabled"},
1423 {name: "disabled", effort: "disabled", wantThinking: "disabled"},
1424 } {
1425 t.Run(tc.name, func(t *testing.T) {
1426 req := (&client{model: "glm-4.5-air", zhipu: true, effort: tc.effort}).buildRequest(provider.Request{})
1427 if req.Thinking == nil || req.Thinking.Type != tc.wantThinking {
1428 t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantThinking)
1429 }
1430 if req.ReasoningEffort != "" {
1431 t.Fatalf("Zhipu must not send reasoning_effort, got %q", req.ReasoningEffort)
1432 }
1433 })
1434 }
1435 }
1436
1437 // TestNewZhipuEffortValidation locks in boot-time validation for the Zhipu path.
1438 // The config effort layer remaps depth levels, so by the time effort reaches the
1439 // factory it must be one of: "", "enabled", "disabled".
1440 func TestNewZhipuEffortValidation(t *testing.T) {
1441 base := provider.Config{Name: "glm", BaseURL: "https://open.bigmodel.cn/api/paas/v4", Model: "glm-4.5-air", APIKey: "k"}
1442 for _, ok := range []string{"", "enabled", "disabled"} {
1443 if _, err := New(withEffort(base, ok)); err != nil {
1444 t.Errorf("effort=%q should be accepted: %v", ok, err)
1445 }
1446 }
1447 for _, bad := range []string{"high", "low", "max", "adaptive"} {
1448 if _, err := New(withEffort(base, bad)); err == nil {
1449 t.Errorf("effort=%q should be rejected", bad)
1450 }
1451 }
1452 }
1453
1454 // TestNewZhipuSetsFlag is a smoke test for base-URL detection across both the
1455 // China (bigmodel.cn) and international (z.ai) GLM endpoints.
1456 func TestNewZhipuSetsFlag(t *testing.T) {
1457 for _, baseURL := range []string{
1458 "https://open.bigmodel.cn/api/paas/v4",
1459 "https://api.z.ai/api/paas/v4",
1460 } {
1461 p, err := New(provider.Config{Name: "glm", BaseURL: baseURL, Model: "glm-4.5-air", APIKey: "k"})
1462 if err != nil {
1463 t.Fatalf("New(%q): %v", baseURL, err)
1464 }
1465 if c := p.(*client); !c.zhipu {
1466 t.Errorf("zhipu flag not set for baseURL=%q", baseURL)
1467 }
1468 }
1469 }
1470
1471 func TestNewExplicitGLMProtocolOnGateway(t *testing.T) {
1472 for _, tc := range []struct {
1473 effort string
1474 want string
1475 }{
1476 {effort: "", want: "enabled"},
1477 {effort: "enabled", want: "enabled"},
1478 {effort: "disabled", want: "disabled"},
1479 } {
1480 p, err := New(provider.Config{
1481 Name: "glm-gateway",
1482 BaseURL: "https://gateway.example.com/v1",
1483 Model: "glm-5.2",
1484 APIKey: "k",
1485 Extra: map[string]any{
1486 "reasoning_protocol": "glm",
1487 "effort": tc.effort,
1488 },
1489 })
1490 if err != nil {
1491 t.Fatalf("New(explicit GLM, effort=%q): %v", tc.effort, err)
1492 }
1493 c := p.(*client)
1494 if !c.zhipu {
1495 t.Fatalf("explicit GLM protocol did not select GLM wire shape")
1496 }
1497 req := c.buildRequest(provider.Request{})
1498 if req.Thinking == nil || req.Thinking.Type != tc.want {
1499 t.Fatalf("effort=%q thinking = %+v, want %q", tc.effort, req.Thinking, tc.want)
1500 }
1501 if req.ReasoningEffort != "" {
1502 t.Fatalf("explicit GLM protocol sent reasoning_effort=%q", req.ReasoningEffort)
1503 }
1504 }
1505 }
1506
1507 func TestBuildRequestRoundTripsGLMReasoningHistory(t *testing.T) {
1508 build := func(effort string) (*client, chatRequest) {
1509 p, err := New(provider.Config{
1510 Name: "glm-gateway",
1511 BaseURL: "https://tokenrhythm.studio/v1",
1512 Model: "glm-5.2",
1513 APIKey: "k",
1514 Extra: map[string]any{
1515 "reasoning_protocol": "glm",
1516 "effort": effort,
1517 },
1518 })
1519 if err != nil {
1520 t.Fatalf("New(GLM, effort=%q): %v", effort, err)
1521 }
1522 c := p.(*client)
1523 out := c.buildRequest(provider.Request{Messages: []provider.Message{
1524 {Role: provider.RoleUser, Content: "inspect"},
1525 {Role: provider.RoleAssistant, ReasoningContent: "read main.go first", ToolCalls: []provider.ToolCall{{
1526 ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
1527 }}},
1528 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
1529 {Role: provider.RoleUser, Content: "continue"},
1530 {Role: provider.RoleAssistant, Content: "done", ReasoningContent: "combine the result"},
1531 }})
1532 return c, out
1533 }
1534
1535 enabled, enabledReq := build("enabled")
1536 if enabled.RequiresToolCallReasoning() || !enabled.RequiresReasoningRoundTrip() {
1537 t.Fatal("thinking-enabled GLM must preserve complete reasoning history without enabling DeepSeek recovery policy")
1538 }
1539 if got := enabledReq.Messages[1].ReasoningContent; got == nil || *got != "read main.go first" {
1540 t.Fatalf("enabled GLM reasoning_content = %v, want provider-issued reasoning", got)
1541 }
1542 if got := enabledReq.Messages[4].ReasoningContent; got == nil || *got != "combine the result" {
1543 t.Fatalf("enabled GLM plain-turn reasoning_content = %v, want complete reasoning history", got)
1544 }
1545 if provider.WarnOnMissingToolCallReasoning(enabled) {
1546 t.Fatal("GLM must preserve available reasoning without entering DeepSeek-specific missing-reasoning recovery")
1547 }
1548
1549 disabled, disabledReq := build("disabled")
1550 if disabled.RequiresToolCallReasoning() || disabled.RequiresReasoningRoundTrip() {
1551 t.Fatal("thinking-disabled GLM must not require new reasoning round trips")
1552 }
1553 if got := disabledReq.Messages[1].ReasoningContent; got == nil || *got != "read main.go first" {
1554 t.Fatalf("disabled GLM must preserve reasoning from an earlier thinking round, got %v", got)
1555 }
1556 if got := disabledReq.Messages[4].ReasoningContent; got == nil || *got != "combine the result" {
1557 t.Fatalf("disabled GLM must preserve plain reasoning from an earlier thinking round, got %v", got)
1558 }
1559 }
1560
1561 // TestBuildRequestGenericThinking covers the vendor-agnostic `thinking` config
1562 // field on a provider we don't auto-detect: thinking.type is emitted as set, and
1563 // an empty/unset field leaves thinking off the wire entirely.
1564 func TestBuildRequestGenericThinking(t *testing.T) {
1565 for _, tc := range []struct {
1566 name string
1567 thinking string
1568 wantType string // "" means no thinking field
1569 }{
1570 {name: "enabled", thinking: "enabled", wantType: "enabled"},
1571 {name: "disabled", thinking: "disabled", wantType: "disabled"},
1572 {name: "unset-omits", thinking: "", wantType: ""},
1573 } {
1574 t.Run(tc.name, func(t *testing.T) {
1575 req := (&client{model: "some-model", thinkingType: tc.thinking}).buildRequest(provider.Request{})
1576 if tc.wantType == "" {
1577 if req.Thinking != nil {
1578 t.Fatalf("expected no thinking, got %+v", req.Thinking)
1579 }
1580 return
1581 }
1582 if req.Thinking == nil || req.Thinking.Type != tc.wantType {
1583 t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantType)
1584 }
1585 })
1586 }
1587 }
1588
1589 // TestNewThinkingConfigParsing pins how the `thinking` config field is read:
1590 // enabled|disabled are kept (case-insensitively), everything else is ignored so
1591 // an unknown value can never break a request.
1592 func TestNewThinkingConfigParsing(t *testing.T) {
1593 base := provider.Config{Name: "gen", BaseURL: "https://api.example.com/v1", Model: "x", APIKey: "k"}
1594 for in, want := range map[string]string{"enabled": "enabled", "DISABLED": "disabled", "adaptive": "", "garbage": "", "": ""} {
1595 cfg := base
1596 cfg.Extra = map[string]any{"thinking": in}
1597 p, err := New(cfg)
1598 if err != nil {
1599 t.Fatalf("New(thinking=%q): %v", in, err)
1600 }
1601 if got := p.(*client).thinkingType; got != want {
1602 t.Errorf("thinking=%q → thinkingType=%q, want %q", in, got, want)
1603 }
1604 }
1605 }
1606
1607 // TestBuildRequestDeepSeekDisabled covers both user-facing ways to turn
1608 // DeepSeek thinking off. Either input must route to thinking.type=disabled,
1609 // drop reasoning_effort, and keep the pre-fix tool-call history bytes: a
1610 // tool_calls turn with no reasoning omits the reasoning_content key entirely
1611 // (only thinking mode requires it), while reasoning left over from a
1612 // thinking-mode round still round-trips so the prompt-cache prefix of a mixed
1613 // thinking-on→off session stays stable.
1614 func TestBuildRequestDeepSeekDisabled(t *testing.T) {
1615 base := provider.Config{Name: "ds", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4", APIKey: "k"}
1616 for _, tc := range []struct {
1617 name string
1618 extra map[string]any
1619 }{
1620 {name: "effort-disabled", extra: map[string]any{"effort": "disabled"}},
1621 {name: "thinking-disabled", extra: map[string]any{"thinking": "disabled"}},
1622 {
1623 name: "effort-disabled-with-explicit-levels",
1624 extra: map[string]any{
1625 "effort": "disabled",
1626 "supported_efforts": []string{"disabled", "high", "max"},
1627 },
1628 },
1629 {
1630 name: "thinking-disabled-overrides-explicit-levels",
1631 extra: map[string]any{
1632 "thinking": "disabled",
1633 "effort": "max",
1634 "supported_efforts": []string{"high"},
1635 },
1636 },
1637 } {
1638 t.Run(tc.name, func(t *testing.T) {
1639 cfg := base
1640 cfg.Extra = tc.extra
1641 p, err := New(cfg)
1642 if err != nil {
1643 t.Fatalf("New(%v): %v", tc.extra, err)
1644 }
1645 req := p.(*client).buildRequest(provider.Request{
1646 Messages: []provider.Message{
1647 {Role: provider.RoleUser, Content: "inspect"},
1648 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
1649 ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
1650 }}},
1651 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
1652 {Role: provider.RoleAssistant, ReasoningContent: "from a thinking round", ToolCalls: []provider.ToolCall{{
1653 ID: "call_2", Name: "read_file", Arguments: `{"path":"go.mod"}`,
1654 }}},
1655 {Role: provider.RoleTool, ToolCallID: "call_2", Name: "read_file", Content: "module demo"},
1656 },
1657 })
1658 if req.Thinking == nil || req.Thinking.Type != "disabled" {
1659 t.Fatalf("Thinking = %+v, want disabled", req.Thinking)
1660 }
1661 if req.ReasoningEffort != "" {
1662 t.Fatalf("disabled DeepSeek must not send reasoning_effort, got %q", req.ReasoningEffort)
1663 }
1664 if rc := req.Messages[1].ReasoningContent; rc != nil {
1665 t.Fatalf("disabled mode must omit reasoning_content on a reasoning-less tool_calls turn, got %q", *rc)
1666 }
1667 if rc := req.Messages[3].ReasoningContent; rc == nil || *rc != "from a thinking round" {
1668 t.Fatalf("disabled mode must keep round-tripping thinking-round reasoning, got %v", rc)
1669 }
1670 })
1671 }
1672 }
1673
1674 func withEffort(c provider.Config, effort string) provider.Config {
1675 extra := c.Extra
1676 if extra == nil {
1677 extra = map[string]any{}
1678 } else {
1679 cp := make(map[string]any, len(extra)+1)
1680 for k, v := range extra {
1681 cp[k] = v
1682 }
1683 extra = cp
1684 }
1685 extra["effort"] = effort
1686 c.Extra = extra
1687 return c
1688 }
1689
1690 func TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {
1691 req := (&client{model: "mimo-v2", effort: "high"}).buildRequest(provider.Request{})
1692 if req.Thinking != nil {
1693 t.Fatalf("non-DeepSeek request must not include thinking, got %+v", req.Thinking)
1694 }
1695 if req.ReasoningEffort != "high" {
1696 t.Fatalf("ReasoningEffort = %q, want high", req.ReasoningEffort)
1697 }
1698 }
1699
1700 func TestNewOllamaCloudReasoningEffort(t *testing.T) {
1701 p, err := New(provider.Config{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "nemotron-3-nano:30b", Extra: map[string]any{"effort": "max"}})
1702 if err != nil {
1703 t.Fatalf("New max: %v", err)
1704 }
1705 c := p.(*client)
1706 if got := c.buildRequest(provider.Request{}).ReasoningEffort; got != "max" {
1707 t.Fatalf("Ollama Cloud reasoning_effort = %q, want max", got)
1708 }
1709
1710 p, err = New(provider.Config{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "nemotron-3-nano:30b", Extra: map[string]any{"effort": "none"}})
1711 if err != nil {
1712 t.Fatalf("New none: %v", err)
1713 }
1714 c = p.(*client)
1715 b, err := json.Marshal(c.buildRequest(provider.Request{}))
1716 if err != nil {
1717 t.Fatalf("marshal none: %v", err)
1718 }
1719 if strings.Contains(string(b), "reasoning_effort") {
1720 t.Fatalf("Ollama Cloud effort none must omit reasoning_effort: %s", b)
1721 }
1722
1723 if _, err := New(provider.Config{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "nemotron-3-nano:30b", Extra: map[string]any{"effort": "ultra"}}); err == nil {
1724 t.Fatal("New invalid effort succeeded, want error")
1725 }
1726 }
1727
1728 func TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {
1729 p, err := New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4"})
1730 if err != nil {
1731 t.Fatalf("New: %v", err)
1732 }
1733 c := p.(*client)
1734 if !c.deepseek || c.effort != "high" {
1735 t.Fatalf("deepseek=%v effort=%q, want true/high", c.deepseek, c.effort)
1736 }
1737
1738 p, err = New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com/v1", Model: "deepseek-v4", Extra: map[string]any{"effort": "max"}})
1739 if err != nil {
1740 t.Fatalf("New max: %v", err)
1741 }
1742 if got := p.(*client).effort; got != "max" {
1743 t.Fatalf("effort = %q, want max", got)
1744 }
1745
1746 if _, err := New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4", Extra: map[string]any{"effort": "medium"}}); err == nil {
1747 t.Fatal("New should reject invalid DeepSeek effort")
1748 }
1749 p, err = New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4", Extra: map[string]any{"effort": "off"}})
1750 if err != nil {
1751 t.Fatalf("New should migrate retired effort=off, not reject it: %v", err)
1752 }
1753 if got := p.(*client).effort; got != "high" {
1754 t.Fatalf("retired effort=off should fall back to high, got %q", got)
1755 }
1756 }
1757
1758 func TestNewReadsEffortFromConfig(t *testing.T) {
1759 p, err := New(provider.Config{
1760 Name: "mimo",
1761 BaseURL: "https://api.example.com",
1762 Model: "mimo-v2",
1763 Extra: map[string]any{"effort": "medium"},
1764 })
1765 if err != nil {
1766 t.Fatalf("New: %v", err)
1767 }
1768 if got := p.(*client).effort; got != "medium" {
1769 t.Errorf("effort = %q, want medium", got)
1770 }
1771 }
1772
1773 func TestStreamReadsReasoningFallbackField(t *testing.T) {
1774 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1775 w.Header().Set("Content-Type", "text/event-stream")
1776 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning":"vllm thinking","content":"answer"}}]}`+"\n\n")
1777 _, _ = io.WriteString(w, "data: [DONE]\n\n")
1778 }))
1779 defer srv.Close()
1780
1781 p, err := New(provider.Config{Name: "vllm", BaseURL: srv.URL, Model: "qwen", APIKey: "k"})
1782 if err != nil {
1783 t.Fatalf("New: %v", err)
1784 }
1785 ch, err := p.Stream(context.Background(), provider.Request{})
1786 if err != nil {
1787 t.Fatalf("Stream: %v", err)
1788 }
1789 var reasoning, text strings.Builder
1790 for chunk := range ch {
1791 switch chunk.Type {
1792 case provider.ChunkReasoning:
1793 reasoning.WriteString(chunk.Text)
1794 case provider.ChunkText:
1795 text.WriteString(chunk.Text)
1796 case provider.ChunkError:
1797 t.Fatalf("stream error: %v", chunk.Err)
1798 }
1799 }
1800 if reasoning.String() != "vllm thinking" {
1801 t.Fatalf("reasoning = %q, want vLLM fallback field", reasoning.String())
1802 }
1803 if text.String() != "answer" {
1804 t.Fatalf("text = %q, want answer", text.String())
1805 }
1806 }
1807
1808 func TestStreamReasoningContentTakesPrecedenceOverFallback(t *testing.T) {
1809 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1810 w.Header().Set("Content-Type", "text/event-stream")
1811 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning_content":"standard","reasoning":"fallback"}}]}`+"\n\n")
1812 _, _ = io.WriteString(w, "data: [DONE]\n\n")
1813 }))
1814 defer srv.Close()
1815
1816 p, err := New(provider.Config{Name: "vllm", BaseURL: srv.URL, Model: "qwen", APIKey: "k"})
1817 if err != nil {
1818 t.Fatalf("New: %v", err)
1819 }
1820 ch, err := p.Stream(context.Background(), provider.Request{})
1821 if err != nil {
1822 t.Fatalf("Stream: %v", err)
1823 }
1824 var reasoning strings.Builder
1825 for chunk := range ch {
1826 switch chunk.Type {
1827 case provider.ChunkReasoning:
1828 reasoning.WriteString(chunk.Text)
1829 case provider.ChunkError:
1830 t.Fatalf("stream error: %v", chunk.Err)
1831 }
1832 }
1833 if reasoning.String() != "standard" {
1834 t.Fatalf("reasoning = %q, want reasoning_content precedence", reasoning.String())
1835 }
1836 }
1837
1838 // TestBuildRequestAlwaysSendsReasoningKeyOnDeepSeekToolCalls proves the wire
1839 // contract verified against the live API: DeepSeek thinking mode 400s an
1840 // assistant tool_calls turn whose reasoning_content KEY is missing from the
1841 // request JSON, but accepts an empty string. A turn whose reasoning was lost
1842 // upstream (gateway renamed/dropped the field, legacy session, model switch)
1843 // must therefore still serialize the key — while plain assistant text turns
1844 // keep omitting it.
1845 func TestBuildRequestAlwaysSendsReasoningKeyOnDeepSeekToolCalls(t *testing.T) {
1846 p, err := New(provider.Config{
1847 Name: "deepseek-proxy",
1848 BaseURL: "https://api.deepseek.com",
1849 Model: "deepseek-v4-pro",
1850 APIKey: "k",
1851 Extra: map[string]any{"reasoning_protocol": "deepseek"},
1852 })
1853 if err != nil {
1854 t.Fatalf("New: %v", err)
1855 }
1856 body, err := json.Marshal(p.(*client).buildRequest(provider.Request{
1857 Messages: []provider.Message{
1858 {Role: provider.RoleUser, Content: "inspect"},
1859 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
1860 ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
1861 }}},
1862 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
1863 {Role: provider.RoleAssistant, Content: "plain text turn"},
1864 },
1865 }))
1866 if err != nil {
1867 t.Fatalf("marshal request: %v", err)
1868 }
1869 var req struct {
1870 Messages []map[string]json.RawMessage `json:"messages"`
1871 }
1872 if err := json.Unmarshal(body, &req); err != nil {
1873 t.Fatalf("unmarshal request: %v", err)
1874 }
1875 if len(req.Messages) != 4 {
1876 t.Fatalf("messages = %d, want 4", len(req.Messages))
1877 }
1878 rc, ok := req.Messages[1]["reasoning_content"]
1879 if !ok {
1880 t.Fatal("tool_calls turn with lost reasoning must still serialize the reasoning_content key")
1881 }
1882 if string(rc) != `""` {
1883 t.Fatalf("reasoning_content = %s, want empty string", rc)
1884 }
1885 if _, ok := req.Messages[3]["reasoning_content"]; ok {
1886 t.Fatal("plain assistant text turn must keep omitting reasoning_content")
1887 }
1888 }
1889
1890 func TestWarnOnMissingToolCallReasoningFollowsDeepSeekThinkingModels(t *testing.T) {
1891 tests := []struct {
1892 model string
1893 want bool
1894 }{
1895 {model: "deepseek-v4-flash", want: true},
1896 {model: "deepseek/deepseek-v4-flash", want: true},
1897 {model: "deepseek-v4-pro", want: true},
1898 {model: "deepseek/deepseek-v4-pro", want: true},
1899 {model: "deepseek-ai/DeepSeek-V4-Pro", want: true},
1900 {model: "deepseek-reasoner", want: true},
1901 {model: "deepseek-ai/DeepSeek-R1-0528", want: true},
1902 {model: "deepseek-ai/DeepSeek-V3.2", want: true},
1903 {model: "deepseek-chat", want: false},
1904 {model: "deepseek-ai/DeepSeek-Prover-V2", want: false},
1905 {model: "custom-model", want: false},
1906 }
1907 for _, tc := range tests {
1908 t.Run(tc.model, func(t *testing.T) {
1909 p, err := New(provider.Config{
1910 Name: "deepseek-proxy",
1911 BaseURL: "https://gateway.example/v1",
1912 Model: tc.model,
1913 APIKey: "k",
1914 Extra: map[string]any{"reasoning_protocol": "deepseek"},
1915 })
1916 if err != nil {
1917 t.Fatalf("New: %v", err)
1918 }
1919 if !provider.RequiresToolCallReasoning(p) {
1920 t.Fatal("DeepSeek protocol should keep conservative reasoning_content replay for tool-call turns")
1921 }
1922 if got := provider.WarnOnMissingToolCallReasoning(p); got != tc.want {
1923 t.Fatalf("WarnOnMissingToolCallReasoning() = %v, want %v", got, tc.want)
1924 }
1925 })
1926 }
1927
1928 explicitThinking, err := New(provider.Config{
1929 Name: "custom-thinking", BaseURL: "https://gateway.example/v1", Model: "custom-model", APIKey: "k",
1930 Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
1931 })
1932 if err != nil {
1933 t.Fatalf("New explicit thinking provider: %v", err)
1934 }
1935 if !provider.WarnOnMissingToolCallReasoning(explicitThinking) {
1936 t.Fatal("explicit DeepSeek thinking must diagnose missing tool-call reasoning")
1937 }
1938
1939 p, err := New(provider.Config{
1940 Name: "deepseek-v4-pro-openai-protocol",
1941 BaseURL: "https://gateway.example/v1",
1942 Model: "deepseek-v4-pro",
1943 APIKey: "k",
1944 Extra: map[string]any{"reasoning_protocol": "openai"},
1945 })
1946 if err != nil {
1947 t.Fatalf("New OpenAI protocol: %v", err)
1948 }
1949 if provider.WarnOnMissingToolCallReasoning(p) {
1950 t.Fatal("OpenAI protocol should not warn using DeepSeek reasoning_content policy")
1951 }
1952 if provider.WarnOnMissingToolCallReasoning(&client{deepseek: true, thinkingType: "disabled"}) {
1953 t.Fatal("disabled thinking must not diagnose missing tool-call reasoning")
1954 }
1955 }
1956
1957 func TestMissingToolCallReasoningWarningFingerprintTracksOpenAIConfiguration(t *testing.T) {
1958 newProvider := func(baseURL, model string) provider.Provider {
1959 p, err := New(provider.Config{
1960 Name: "deepseek", BaseURL: baseURL, Model: model, APIKey: "secret",
1961 Extra: map[string]any{"reasoning_protocol": "deepseek", "effort": "high"},
1962 })
1963 if err != nil {
1964 t.Fatalf("New: %v", err)
1965 }
1966 return p
1967 }
1968 first := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://gateway.example/v1", "deepseek-v4-pro"))
1969 same := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://gateway.example/v1", "deepseek-v4-pro"))
1970 changedEndpoint := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://other.example/v1", "deepseek-v4-pro"))
1971 changedModel := provider.MissingToolCallReasoningWarningFingerprint(newProvider("https://gateway.example/v1", "deepseek-v4-flash"))
1972 if first != same {
1973 t.Fatal("equivalent OpenAI configurations produced different fingerprints")
1974 }
1975 if first == changedEndpoint || first == changedModel {
1976 t.Fatal("endpoint or model change did not re-key the warning fingerprint")
1977 }
1978 if len(first) != 64 || strings.Contains(first, "gateway") || strings.Contains(first, "deepseek") {
1979 t.Fatalf("fingerprint is not an opaque SHA-256 digest: %q", first)
1980 }
1981 }
1982
1983 // TestBuildRequestRoundTripsDeepSeekToolCallReasoning keeps the healthy-path
1984 // bytes intact: when the session has the provider-issued reasoning, it is
1985 // replayed verbatim on the tool_calls turn.
1986 func TestBuildRequestRoundTripsDeepSeekToolCallReasoning(t *testing.T) {
1987 p, err := New(provider.Config{
1988 Name: "deepseek-proxy",
1989 BaseURL: "https://api.deepseek.com",
1990 Model: "deepseek-v4-pro",
1991 APIKey: "k",
1992 Extra: map[string]any{"reasoning_protocol": "deepseek"},
1993 })
1994 if err != nil {
1995 t.Fatalf("New: %v", err)
1996 }
1997 out := p.(*client).buildRequest(provider.Request{
1998 Messages: []provider.Message{
1999 {Role: provider.RoleUser, Content: "inspect"},
2000 {Role: provider.RoleAssistant, ReasoningContent: "read main.go first", ToolCalls: []provider.ToolCall{{
2001 ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
2002 }}},
2003 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
2004 },
2005 })
2006 got := out.Messages[1].ReasoningContent
2007 if got == nil || *got != "read main.go first" {
2008 t.Fatalf("reasoning_content = %v, want provider-issued reasoning round-tripped", got)
2009 }
2010 }
2011
2012 // TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose
2013 // calls carry no id (some OpenAI-compatible gateways omit it, sending only the
2014 // index) keeps every tool result through buildRequest. SanitizeToolPairing keys
2015 // on tool_call_id, so empty ids collapse and all but the last result is dropped.
2016 func TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {
2017 c := &client{model: "deepseek-v4"}
2018 req := c.buildRequest(provider.Request{
2019 Messages: []provider.Message{
2020 {Role: provider.RoleUser, Content: "scan"},
2021 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
2022 {ID: "", Name: "read_file", Arguments: `{"p":"a"}`},
2023 {ID: "", Name: "read_file", Arguments: `{"p":"b"}`},
2024 }},
2025 {Role: provider.RoleTool, ToolCallID: "", Name: "read_file", Content: "RESULT-A"},
2026 {Role: provider.RoleTool, ToolCallID: "", Name: "read_file", Content: "RESULT-B"},
2027 },
2028 })
2029 var toolContents []string
2030 for _, m := range req.Messages {
2031 if m.Role == string(provider.RoleTool) {
2032 if s, ok := m.Content.(string); ok {
2033 toolContents = append(toolContents, s)
2034 }
2035 }
2036 }
2037 if len(toolContents) != 2 {
2038 t.Fatalf("want 2 tool results in request, got %d: %v", len(toolContents), toolContents)
2039 }
2040 if toolContents[0] == toolContents[1] {
2041 t.Errorf("tool results collapsed to %q — a result was dropped from the model's context", toolContents[0])
2042 }
2043 }
2044
2045 // TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool
2046 // calls by index with no id (vLLM / llama.cpp do this). Each completed call must
2047 // come back with a stable, distinct synthetic id so its result can pair back.
2048 func TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {
2049 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2050 w.Header().Set("Content-Type", "text/event-stream")
2051 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\"p\":\"a\"}"}}]}}]}`+"\n\n")
2052 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"read_file","arguments":"{\"p\":\"b\"}"}}]}}]}`+"\n\n")
2053 _, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`+"\n\n")
2054 _, _ = io.WriteString(w, "data: [DONE]\n\n")
2055 }))
2056 defer srv.Close()
2057
2058 p, err := New(provider.Config{Name: "local", BaseURL: srv.URL, Model: "qwen", APIKey: "k"})
2059 if err != nil {
2060 t.Fatalf("New: %v", err)
2061 }
2062 ch, err := p.Stream(context.Background(), provider.Request{})
2063 if err != nil {
2064 t.Fatalf("Stream: %v", err)
2065 }
2066 var ids []string
2067 for chunk := range ch {
2068 if chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {
2069 ids = append(ids, chunk.ToolCall.ID)
2070 }
2071 }
2072 if len(ids) != 2 {
2073 t.Fatalf("want 2 tool calls, got %d: %v", len(ids), ids)
2074 }
2075 if ids[0] == "" || ids[1] == "" {
2076 t.Errorf("a tool call came back with an empty id: %v", ids)
2077 }
2078 if ids[0] == ids[1] {
2079 t.Errorf("synthesized ids must be distinct, got %v", ids)
2080 }
2081 }
2082
2083 func TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {
2084 c := &client{name: "x", model: "m", baseURL: "https://api.example.com/v1"}
2085 req := provider.Request{
2086 Messages: []provider.Message{
2087 {Role: provider.RoleAssistant, Content: "", ToolCalls: []provider.ToolCall{{ID: "c1", Name: "ls", Arguments: `{}`}}},
2088 {Role: provider.RoleTool, Content: "", ToolCallID: "c1", Name: "ls"},
2089 {Role: provider.RoleAssistant, Content: "all done"},
2090 },
2091 Tools: []provider.ToolSchema{{Name: "noargs", Parameters: provider.CanonicalizeSchema(nil)}},
2092 }
2093 body, err := json.Marshal(c.buildRequest(req))
2094 if err != nil {
2095 t.Fatalf("marshal: %v", err)
2096 }
2097 if !json.Valid(body) {
2098 t.Fatalf("invalid JSON body: %s", body)
2099 }
2100 s := string(body)
2101 if !strings.Contains(s, `"tool_calls"`) || !strings.Contains(s, `"content":null`) {
2102 t.Errorf("assistant tool_calls turn should carry null content: %s", s)
2103 }
2104 if !strings.Contains(s, `{"role":"tool","content":""`) {
2105 t.Errorf("tool message should keep empty-string content, not null: %s", s)
2106 }
2107 if !strings.Contains(s, `"content":"all done"`) {
2108 t.Errorf("text assistant turn should keep its string content: %s", s)
2109 }
2110 if !strings.Contains(s, `"parameters":{"properties":{},"type":"object"}`) {
2111 t.Errorf("no-param tool should serialize a strict empty-object schema: %s", s)
2112 }
2113 }
2114
2115 func TestBuildRequestOmitsResponseOnlyToolCallIndex(t *testing.T) {
2116 c := &client{name: "x", model: "m", baseURL: "https://api.example.com/v1"}
2117 req := provider.Request{
2118 Messages: []provider.Message{{
2119 Role: provider.RoleAssistant,
2120 ToolCalls: []provider.ToolCall{{
2121 ID: "call_1",
2122 Name: "bash",
2123 Arguments: `{"cmd":"ls"}`,
2124 }},
2125 }},
2126 }
2127 body, err := json.Marshal(c.buildRequest(req))
2128 if err != nil {
2129 t.Fatalf("marshal: %v", err)
2130 }
2131 s := string(body)
2132 if !strings.Contains(s, `"tool_calls"`) {
2133 t.Fatalf("request body missing tool call: %s", s)
2134 }
2135 if strings.Contains(s, `"index"`) {
2136 t.Fatalf("request body contains response-only tool_call index: %s", s)
2137 }
2138 }
2139
2140 func TestBuildRequestDefaultsEmptyToolParameters(t *testing.T) {
2141 c := &client{name: "x", model: "m", baseURL: "https://api.example.com/v1"}
2142 req := provider.Request{
2143 Tools: []provider.ToolSchema{{Name: "noargs"}},
2144 }
2145 body, err := json.Marshal(c.buildRequest(req))
2146 if err != nil {
2147 t.Fatalf("marshal: %v", err)
2148 }
2149 var wire struct {
2150 Tools []struct {
2151 Function map[string]json.RawMessage `json:"function"`
2152 } `json:"tools"`
2153 }
2154 if err := json.Unmarshal(body, &wire); err != nil {
2155 t.Fatalf("unmarshal request: %v\n%s", err, body)
2156 }
2157 if len(wire.Tools) != 1 {
2158 t.Fatalf("tools = %d, want 1: %s", len(wire.Tools), body)
2159 }
2160 fn := wire.Tools[0].Function
2161 if string(fn["name"]) != `"noargs"` {
2162 t.Fatalf("function name = %s, want noargs", fn["name"])
2163 }
2164 if _, ok := fn["description"]; ok {
2165 t.Fatalf("empty description should be omitted: %s", body)
2166 }
2167 if got, want := string(fn["parameters"]), `{"properties":{},"type":"object"}`; got != want {
2168 t.Fatalf("nil parameters should default to %s, got %s in %s", want, got, body)
2169 }
2170 }
2171
2172 func TestStreamReadsGeminiThoughtSignature(t *testing.T) {
2173 tests := []struct {
2174 name string
2175 toolCall string
2176 }{
2177 {
2178 name: "current extra_content shape",
2179 toolCall: `{"index":0,"id":"call_abc123","type":"function","extra_content":{"google":{"thought_signature":"gemini_sig_xyz789"}},"function":{"name":"write_file"}}`,
2180 },
2181 {
2182 name: "legacy function shape",
2183 toolCall: `{"index":0,"id":"call_abc123","type":"function","function":{"name":"write_file","thought_signature":"gemini_sig_xyz789"}}`,
2184 },
2185 }
2186 for _, tc := range tests {
2187 t.Run(tc.name, func(t *testing.T) {
2188 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2189 w.Header().Set("Content-Type", "text/event-stream")
2190 _, _ = io.WriteString(w,
2191 "data: {\"choices\":[{\"delta\":{\"tool_calls\":["+tc.toolCall+"]}}]}\n\n"+
2192 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"test.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n"+
2193 "data: [DONE]\n\n")
2194 }))
2195 defer srv.Close()
2196
2197 p, err := New(provider.Config{Name: "gemini", BaseURL: srv.URL, Model: "gemini-3.6-flash"})
2198 if err != nil {
2199 t.Fatalf("New: %v", err)
2200 }
2201
2202 ch, err := p.Stream(context.Background(), provider.Request{})
2203 if err != nil {
2204 t.Fatalf("Stream: %v", err)
2205 }
2206
2207 var got *provider.ToolCall
2208 for chunk := range ch {
2209 if chunk.Type == provider.ChunkToolCall {
2210 got = chunk.ToolCall
2211 }
2212 }
2213 if got == nil {
2214 t.Fatal("expected ChunkToolCall but none received")
2215 }
2216 if got.ThoughtSignature != "gemini_sig_xyz789" {
2217 t.Errorf("ThoughtSignature = %q, want %q", got.ThoughtSignature, "gemini_sig_xyz789")
2218 }
2219 if got.Arguments != `{"path":"test.txt"}` {
2220 t.Errorf("Arguments = %q, want complete streamed arguments", got.Arguments)
2221 }
2222 })
2223 }
2224 }
2225
2226 func TestBuildRequestScopesGeminiThoughtSignature(t *testing.T) {
2227 req := provider.Request{
2228 Messages: []provider.Message{{
2229 Role: provider.RoleAssistant,
2230 ToolCalls: []provider.ToolCall{{
2231 ID: "call_abc123",
2232 Name: "write_file",
2233 Arguments: `{"path":"test.txt"}`,
2234 ThoughtSignature: "gemini_sig_xyz789",
2235 }},
2236 }},
2237 }
2238
2239 for _, tc := range []struct {
2240 name string
2241 baseURL string
2242 model string
2243 wantSignature string
2244 }{
2245 {"official Gemini endpoint", "https://generativelanguage.googleapis.com/v1beta/openai", "custom-alias", "gemini_sig_xyz789"},
2246 {"Gemini-compatible gateway", "https://openrouter.ai/api/v1", "google/gemini-3.1-pro", "gemini_sig_xyz789"},
2247 {"same history after provider switch", "https://api.deepseek.com/v1", "deepseek-chat", ""},
2248 } {
2249 t.Run(tc.name, func(t *testing.T) {
2250 c := &client{name: tc.name, baseURL: tc.baseURL, model: tc.model}
2251 body, err := json.Marshal(c.buildRequest(req))
2252 if err != nil {
2253 t.Fatalf("marshal request: %v", err)
2254 }
2255 var wire struct {
2256 Messages []struct {
2257 ToolCalls []struct {
2258 ExtraContent *struct {
2259 Google struct {
2260 ThoughtSignature string `json:"thought_signature"`
2261 } `json:"google"`
2262 } `json:"extra_content"`
2263 Function struct {
2264 ThoughtSignature string `json:"thought_signature"`
2265 } `json:"function"`
2266 } `json:"tool_calls"`
2267 } `json:"messages"`
2268 }
2269 if err := json.Unmarshal(body, &wire); err != nil {
2270 t.Fatalf("unmarshal request: %v", err)
2271 }
2272 if len(wire.Messages) == 0 || len(wire.Messages[0].ToolCalls) != 1 {
2273 t.Fatalf("unexpected request shape: %s", body)
2274 }
2275 toolCall := wire.Messages[0].ToolCalls[0]
2276 gotSignature := ""
2277 if toolCall.ExtraContent != nil {
2278 gotSignature = toolCall.ExtraContent.Google.ThoughtSignature
2279 }
2280 if gotSignature != tc.wantSignature {
2281 t.Errorf("thought_signature = %q, want %q", gotSignature, tc.wantSignature)
2282 }
2283 if tc.wantSignature == "" && toolCall.ExtraContent != nil {
2284 t.Errorf("non-Gemini request should omit extra_content: %s", body)
2285 }
2286 if got := toolCall.Function.ThoughtSignature; got != "" {
2287 t.Errorf("legacy function.thought_signature should not be sent, got %q", got)
2288 }
2289 })
2290 }
2291 }
2292
2293 func TestNormaliseUsageAnthropicStyleFallback(t *testing.T) {
2294 tests := []struct {
2295 name string
2296 json string
2297 want provider.Usage
2298 }{
2299 {
2300 name: "cache hit",
2301 json: `{"usage":{"input_tokens":21,"output_tokens":393,"cache_creation_input_tokens":0,"cache_read_input_tokens":188086}}`,
2302 want: provider.Usage{
2303 PromptTokens: 188107,
2304 CompletionTokens: 393,
2305 TotalTokens: 188500,
2306 CacheHitTokens: 188086,
2307 CacheMissTokens: 21,
2308 },
2309 },
2310 {
2311 name: "cache creation",
2312 json: `{"usage":{"input_tokens":21,"output_tokens":393,"cache_creation_input_tokens":188086,"cache_read_input_tokens":0}}`,
2313 want: provider.Usage{
2314 PromptTokens: 188107,
2315 CompletionTokens: 393,
2316 TotalTokens: 188500,
2317 CacheMissTokens: 188107,
2318 },
2319 },
2320 {
2321 name: "DeepSeek fields take priority",
2322 json: `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":30,"prompt_cache_miss_tokens":70,"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":888,"cache_read_input_tokens":999}}`,
2323 want: provider.Usage{
2324 PromptTokens: 100,
2325 CompletionTokens: 50,
2326 TotalTokens: 150,
2327 CacheHitTokens: 30,
2328 CacheMissTokens: 70,
2329 },
2330 },
2331 {
2332 name: "OpenAI nested fields take priority",
2333 json: `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_tokens_details":{"cached_tokens":40},"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":888,"cache_read_input_tokens":999}}`,
2334 want: provider.Usage{
2335 PromptTokens: 100,
2336 CompletionTokens: 50,
2337 TotalTokens: 150,
2338 CacheHitTokens: 40,
2339 CacheMissTokens: 60,
2340 },
2341 },
2342 }
2343
2344 for _, tc := range tests {
2345 t.Run(tc.name, func(t *testing.T) {
2346 var response streamResponse
2347 if err := json.Unmarshal([]byte(tc.json), &response); err != nil {
2348 t.Fatalf("unmarshal usage fixture: %v", err)
2349 }
2350 if response.Usage == nil {
2351 t.Fatal("usage fixture did not decode usage")
2352 }
2353 if got := normaliseUsage(response.Usage); got == nil || *got != tc.want {
2354 t.Fatalf("normaliseUsage() = %+v, want %+v", got, tc.want)
2355 }
2356 })
2357 }
2358 }
2359
2359 lines GO