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