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