返回 DeepSeek-Reasonix
responses_test.go
根目录 / internal / provider / responses / responses_test.go
1 package responses
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "reasonix/internal/provider"
16 )
17
18 func boolPtr(value bool) *bool { return &value }
19
20 func collect(t *testing.T, p provider.Provider, req provider.Request) []provider.Chunk {
21 t.Helper()
22 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
23 defer cancel()
24 stream, err := p.Stream(ctx, req)
25 if err != nil {
26 t.Fatalf("Stream: %v", err)
27 }
28 var chunks []provider.Chunk
29 for chunk := range stream {
30 chunks = append(chunks, chunk)
31 }
32 return chunks
33 }
34
35 func writeEvents(w http.ResponseWriter, events ...string) {
36 w.Header().Set("Content-Type", "text/event-stream")
37 for _, event := range events {
38 _, _ = w.Write([]byte("event: ignored\n"))
39 _, _ = w.Write([]byte("data: " + event + "\n\n"))
40 }
41 if flusher, ok := w.(http.Flusher); ok {
42 flusher.Flush()
43 }
44 }
45
46 func TestDetectVendorAndModeDefaults(t *testing.T) {
47 tests := []struct{ url, vendor, mode string }{
48 {"https://api.deepseek.com", "deepseek", "stateless"},
49 {"https://eu.deepseek.com/v1", "deepseek", "stateless"},
50 {"https://api.xiaomimimo.com/v1", "mimo", "stateless"},
51 {"https://dashscope.aliyuncs.com/compatible-mode/v1", "dashscope", "stateful"},
52 {"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", "dashscope", "stateful"},
53 {"https://api.deepseek.com.attacker.example/v1", "", "stateful"},
54 {"https://example.com/api.deepseek.com/v1", "", "stateful"},
55 {"https://example.com/v1", "", "stateful"},
56 }
57 for _, test := range tests {
58 if got := DetectVendor(test.url); got != test.vendor {
59 t.Errorf("DetectVendor(%q) = %q, want %q", test.url, got, test.vendor)
60 }
61 if got := (Config{BaseURL: test.url}).mode(); got != test.mode {
62 t.Errorf("mode(%q) = %q, want %q", test.url, got, test.mode)
63 }
64 }
65 if got := (Config{BaseURL: "https://api.deepseek.com", Mode: "stateful"}).mode(); got != "stateful" {
66 t.Fatalf("explicit mode = %q", got)
67 }
68 if got := (Config{BaseURL: "https://api.deepseek.com", Mode: "stateful", Stateful: boolPtr(false)}).mode(); got != "stateful" {
69 t.Fatalf("mode must win over legacy stateful, got %q", got)
70 }
71 if got := (Config{BaseURL: "https://example.com", Stateful: boolPtr(false)}).mode(); got != "stateless" {
72 t.Fatalf("legacy stateful=false mode = %q", got)
73 }
74 }
75
76 func TestDeepSeekEffortUsesResponsesReasoningShape(t *testing.T) {
77 tests := []struct{ effort, want string }{
78 {"auto", ""}, {"disabled", "none"}, {"minimal", "minimal"}, {"low", "low"}, {"high", "high"}, {"max", "max"},
79 }
80 for _, test := range tests {
81 client := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: test.effort}).(*client)
82 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
83 reasoning, _ := body["reasoning"].(map[string]any)
84 got, _ := reasoning["effort"].(string)
85 if got != test.want {
86 t.Errorf("effort %q serialized as %q, want %q", test.effort, got, test.want)
87 }
88 }
89 }
90
91 func TestRequestSerializesExplicitMaxOutputTokens(t *testing.T) {
92 client := New(Config{Name: "responses", BaseURL: "https://example.com", Model: "model"}).(*client)
93 body, _, _ := client.buildRequestBody(provider.Request{
94 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
95 MaxTokens: 32 * 1024,
96 })
97 if got := body["max_output_tokens"]; got != 32*1024 {
98 t.Fatalf("max_output_tokens = %#v, want 32768", got)
99 }
100 }
101
102 func TestRequestUsesOnlySafeProviderOutputDefaults(t *testing.T) {
103 message := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
104
105 deepseek := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
106 deepseekBody, _, _ := deepseek.buildRequestBody(provider.Request{Messages: message})
107 if got := deepseekBody["max_output_tokens"]; got != provider.DefaultReasoningOutputTokens {
108 t.Fatalf("DeepSeek max_output_tokens = %#v, want %d", got, provider.DefaultReasoningOutputTokens)
109 }
110
111 for _, effort := range []string{"none", "disabled", "off", " NONE "} {
112 thinkingDisabled := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: effort}).(*client)
113 thinkingDisabledBody, _, _ := thinkingDisabled.buildRequestBody(provider.Request{Messages: message})
114 if _, exists := thinkingDisabledBody["max_output_tokens"]; exists {
115 t.Fatalf("thinking-disabled DeepSeek effort %q received an automatic output budget: %#v", effort, thinkingDisabledBody)
116 }
117 }
118
119 explicitThinkingDisabled := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: "none", MaxOutputTokens: 8192}).(*client)
120 explicitThinkingDisabledBody, _, _ := explicitThinkingDisabled.buildRequestBody(provider.Request{Messages: message})
121 if got := explicitThinkingDisabledBody["max_output_tokens"]; got != 8192 {
122 t.Fatalf("explicit thinking-disabled DeepSeek budget = %#v, want 8192", got)
123 }
124
125 unknown := New(Config{Name: "responses", BaseURL: "https://example.com", Model: "model"}).(*client)
126 unknownBody, _, _ := unknown.buildRequestBody(provider.Request{Messages: message})
127 if _, exists := unknownBody["max_output_tokens"]; exists {
128 t.Fatalf("unknown Responses endpoint received an inferred output budget: %#v", unknownBody)
129 }
130
131 disabled := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", MaxOutputTokens: -1}).(*client)
132 disabledBody, _, _ := disabled.buildRequestBody(provider.Request{Messages: message})
133 if _, exists := disabledBody["max_output_tokens"]; exists {
134 t.Fatalf("disabled DeepSeek Responses budget remained present: %#v", disabledBody)
135 }
136 }
137
138 func TestFactoryPreservesUnsetLegacyStatefulForVendorDetection(t *testing.T) {
139 p, err := newFromConfig(provider.Config{
140 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash",
141 Extra: map[string]any{"stateful": (*bool)(nil)},
142 })
143 if err != nil {
144 t.Fatalf("newFromConfig: %v", err)
145 }
146 if got := p.(*client).mode; got != "stateless" {
147 t.Fatalf("unset stateful mode = %q, want DeepSeek vendor default stateless", got)
148 }
149 }
150
151 func TestFactoryPropagatesWebSearch(t *testing.T) {
152 p, err := newFromConfig(provider.Config{
153 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash",
154 Extra: map[string]any{"web_search": true},
155 })
156 if err != nil {
157 t.Fatalf("newFromConfig: %v", err)
158 }
159 if !p.(*client).webSearch {
160 t.Fatal("web_search was not propagated to the Responses client")
161 }
162 }
163
164 func TestWebSearchToolPrecedesFunctionTools(t *testing.T) {
165 client := New(Config{
166 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", WebSearch: true,
167 }).(*client)
168 body, _, _ := client.buildRequestBody(provider.Request{
169 Messages: []provider.Message{{Role: provider.RoleUser, Content: "latest release"}},
170 Tools: []provider.ToolSchema{{Name: "read_file", Description: "Read a file", Parameters: json.RawMessage(`{"type":"object"}`)}},
171 })
172 tools, ok := body["tools"].([]map[string]any)
173 if !ok || len(tools) != 2 {
174 t.Fatalf("tools = %#v, want web_search plus one function", body["tools"])
175 }
176 if got := tools[0]["type"]; got != "web_search" {
177 t.Fatalf("tools[0] = %#v, want stable web_search first", tools[0])
178 }
179 if got := tools[1]["type"]; got != "function" {
180 t.Fatalf("tools[1] = %#v, want function tool", tools[1])
181 }
182 }
183
184 func TestStatelessRequestReplaysReasoningContentAndToolPair(t *testing.T) {
185 var body map[string]any
186 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
188 http.Error(w, err.Error(), http.StatusBadRequest)
189 return
190 }
191 writeEvents(w, `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}}`)
192 }))
193 defer server.Close()
194
195 p := New(Config{Name: "deepseek", APIKey: "key", BaseURL: server.URL, Model: "deepseek-v4-flash", Mode: "stateless", Effort: "high"})
196 collect(t, p, provider.Request{Messages: []provider.Message{
197 {Role: provider.RoleSystem, Content: "system"},
198 {Role: provider.RoleUser, Content: "weather"},
199 {Role: provider.RoleAssistant, Content: "checking", ReasoningContent: "need a tool", ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "weather", Arguments: `{"city":"SG"}`}}},
200 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "weather", Content: "sunny"},
201 }})
202
203 if body["previous_response_id"] != nil {
204 t.Fatalf("stateless request has previous_response_id: %#v", body)
205 }
206 if body["instructions"] != "system" {
207 t.Fatalf("instructions = %#v", body["instructions"])
208 }
209 items, ok := body["input"].([]any)
210 if !ok || len(items) != 5 {
211 t.Fatalf("input = %#v, want user/reasoning/assistant/call/output", body["input"])
212 }
213 wantTypes := []string{"", "reasoning", "", "function_call", "function_call_output"}
214 for i, want := range wantTypes {
215 item := items[i].(map[string]any)
216 if got, _ := item["type"].(string); got != want {
217 t.Errorf("item[%d].type = %q, want %q: %#v", i, got, want, item)
218 }
219 }
220 assistant := items[2].(map[string]any)
221 if assistant["content"] != "checking" {
222 t.Fatalf("assistant content lost: %#v", assistant)
223 }
224 reasoning := items[1].(map[string]any)["content"].([]any)[0].(map[string]any)
225 if reasoning["type"] != "reasoning_text" || reasoning["text"] != "need a tool" {
226 t.Fatalf("reasoning item = %#v", reasoning)
227 }
228 }
229
230 func TestStatelessRequestSanitizesMissingToolOutput(t *testing.T) {
231 client := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
232 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
233 {Role: provider.RoleUser, Content: "run"},
234 {Role: provider.RoleAssistant, ReasoningContent: "call", ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"command":"pwd"}`}}},
235 }})
236 items := body["input"].([]map[string]any)
237 if got := items[len(items)-1]["type"]; got != "function_call_output" {
238 t.Fatalf("last input item = %#v, want repaired function_call_output", items[len(items)-1])
239 }
240 }
241
242 func TestStreamDoesNotDuplicateDoneText(t *testing.T) {
243 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
244 writeEvents(w,
245 `{"type":"response.output_text.delta","item_id":"msg_1","content_index":0,"delta":"hello"}`,
246 `{"type":"response.output_text.done","item_id":"msg_1","content_index":0,"text":"hello"}`,
247 `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"input_tokens_details":{"cached_tokens":2},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":1},"total_tokens":4}}}`,
248 )
249 }))
250 defer server.Close()
251
252 chunks := collect(t, New(Config{Name: "test", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateless"}), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
253 var text string
254 var usage *provider.Usage
255 for _, chunk := range chunks {
256 if chunk.Type == provider.ChunkText {
257 text += chunk.Text
258 }
259 if chunk.Type == provider.ChunkUsage {
260 usage = chunk.Usage
261 }
262 }
263 if text != "hello" {
264 t.Fatalf("streamed text = %q, want one copy", text)
265 }
266 if usage == nil || usage.CacheHitTokens != 2 || usage.CacheMissTokens != 1 || usage.ReasoningTokens != 1 || usage.RequestCount != 1 {
267 t.Fatalf("usage = %+v", usage)
268 }
269 if chunks[len(chunks)-1].Type != provider.ChunkDone {
270 t.Fatalf("last chunk = %v", chunks[len(chunks)-1].Type)
271 }
272 }
273
274 func TestStreamToleratesWebSearchLifecycleEvents(t *testing.T) {
275 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
276 writeEvents(w,
277 `{"type":"response.web_search_call.in_progress","item_id":"ws_1"}`,
278 `{"type":"response.web_search_call.searching","item_id":"ws_1"}`,
279 `{"type":"response.web_search_call.completed","item_id":"ws_1"}`,
280 `{"type":"response.output_text.delta","item_id":"msg_1","content_index":0,"delta":"found it"}`,
281 `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":4,"output_tokens":2,"total_tokens":6}}}`,
282 )
283 }))
284 defer server.Close()
285
286 chunks := collect(t, New(Config{Name: "deepseek", APIKey: "key", BaseURL: server.URL, Model: "deepseek-v4-flash", Mode: "stateless", WebSearch: true}), provider.Request{
287 Messages: []provider.Message{{Role: provider.RoleUser, Content: "search"}},
288 })
289 var text string
290 for _, chunk := range chunks {
291 if chunk.Type == provider.ChunkText {
292 text += chunk.Text
293 }
294 if chunk.Type == provider.ChunkError {
295 t.Fatalf("unexpected stream error: %v", chunk.Err)
296 }
297 }
298 if text != "found it" || chunks[len(chunks)-1].Type != provider.ChunkDone {
299 t.Fatalf("chunks = %#v, want searched answer followed by done", chunks)
300 }
301 }
302
303 func TestDeepSeekStatelessReplayPreservesCompletedWebSearchCall(t *testing.T) {
304 var bodies []map[string]any
305 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
306 var body map[string]any
307 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
308 http.Error(w, err.Error(), http.StatusBadRequest)
309 return
310 }
311 bodies = append(bodies, body)
312 if len(bodies) == 1 {
313 writeEvents(w,
314 `{"type":"response.output_item.done","item":{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"latest release","sources":[{"url":"https://api-docs.deepseek.com/updates/"}]}}}`,
315 `{"type":"response.output_text.delta","item_id":"msg_1","content_index":0,"delta":"found it"}`,
316 `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":4,"output_tokens":2,"total_tokens":6}}}`,
317 )
318 return
319 }
320 writeEvents(w, `{"type":"response.completed","response":{"id":"resp_2","usage":{"input_tokens":8,"output_tokens":2,"total_tokens":10}}}`)
321 }))
322 defer server.Close()
323
324 client := New(Config{Name: "deepseek", APIKey: "key", BaseURL: server.URL, Model: "deepseek-v4-flash", Mode: "stateless", WebSearch: true}).(*client)
325 // The test server is local, so pin the vendor classification to the official
326 // DeepSeek behavior under test without weakening production URL detection.
327 client.vendor = "deepseek"
328 first := collect(t, client, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "search"}}})
329 var replayItems []json.RawMessage
330 for _, chunk := range first {
331 if chunk.Type == provider.ChunkResponsesItem {
332 replayItems = append(replayItems, chunk.ResponsesItem)
333 }
334 }
335 if len(replayItems) != 1 {
336 t.Fatalf("replay items = %d, want one completed web_search_call: %#v", len(replayItems), first)
337 }
338
339 collect(t, client, provider.Request{Messages: []provider.Message{
340 {Role: provider.RoleUser, Content: "search"},
341 {Role: provider.RoleAssistant, Content: "found it", ResponsesItems: replayItems},
342 {Role: provider.RoleUser, Content: "which source?"},
343 }})
344 if len(bodies) != 2 {
345 t.Fatalf("request bodies = %d, want 2", len(bodies))
346 }
347 items, ok := bodies[1]["input"].([]any)
348 if !ok || len(items) != 4 {
349 t.Fatalf("follow-up input = %#v, want user/search-call/assistant/user", bodies[1]["input"])
350 }
351 search, ok := items[1].(map[string]any)
352 if !ok || search["type"] != "web_search_call" || search["id"] != "ws_1" {
353 t.Fatalf("replayed search item = %#v", items[1])
354 }
355 action, _ := search["action"].(map[string]any)
356 if action["query"] != "latest release" {
357 t.Fatalf("replayed search action = %#v", action)
358 }
359 }
360
361 func TestResponsesItemsAreIgnoredOutsideOfficialDeepSeekWire(t *testing.T) {
362 raw := json.RawMessage(`{"id":"ws_1","type":"web_search_call","status":"completed"}`)
363 client := New(Config{Name: "compatible", BaseURL: "https://gateway.example", Model: "m", Mode: "stateless"}).(*client)
364 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
365 {Role: provider.RoleUser, Content: "search"},
366 {Role: provider.RoleAssistant, Content: "answer", ResponsesItems: []json.RawMessage{raw}},
367 }})
368 items := body["input"].([]map[string]any)
369 for _, item := range items {
370 if item["type"] == "web_search_call" {
371 t.Fatalf("foreign Responses endpoint received DeepSeek replay item: %#v", items)
372 }
373 }
374 }
375
376 func TestDeepSeekReplayDropsMalformedOrIncompleteSearchItems(t *testing.T) {
377 items := []json.RawMessage{
378 json.RawMessage(`{"id":"ws_valid","type":"web_search_call","status":"completed","action":{"type":"search"}}`),
379 json.RawMessage(`{"id":"ws_failed","type":"web_search_call","status":"failed"}`),
380 json.RawMessage(`{"type":"web_search_call","status":"completed"}`),
381 json.RawMessage(`{"id":"fc_1","type":"function_call","status":"completed"}`),
382 json.RawMessage(`{"id":`),
383 }
384 client := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Mode: "stateless"}).(*client)
385 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
386 {Role: provider.RoleUser, Content: "search"},
387 {Role: provider.RoleAssistant, Content: "answer", ResponsesItems: items},
388 }})
389 wire := body["input"].([]map[string]any)
390 var searches []map[string]any
391 for _, item := range wire {
392 if item["type"] == "web_search_call" {
393 searches = append(searches, item)
394 }
395 }
396 if len(searches) != 1 || searches[0]["id"] != "ws_valid" {
397 t.Fatalf("replayed searches = %#v, want only completed valid item", searches)
398 }
399 }
400
401 func TestFunctionArgumentEventsUseOutputItemMappingAndCumulativeProgress(t *testing.T) {
402 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
403 writeEvents(w,
404 `{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"bash"}}`,
405 `{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"command\":"}`,
406 `{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"pwd\"}"}`,
407 `{"type":"response.function_call_arguments.done","item_id":"fc_1","arguments":"{\"command\":\"pwd\"}"}`,
408 `{"type":"response.output_item.done","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"pwd\"}"}}`,
409 `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":2,"output_tokens":2,"total_tokens":4}}}`,
410 )
411 }))
412 defer server.Close()
413
414 chunks := collect(t, New(Config{Name: "test", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateless"}), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "pwd"}}})
415 var starts, completed int
416 var progress []int
417 for _, chunk := range chunks {
418 switch chunk.Type {
419 case provider.ChunkToolCallStart:
420 starts++
421 if chunk.ToolCall.ID != "call_1" || chunk.ToolCall.Name != "bash" {
422 t.Errorf("start = %+v", chunk.ToolCall)
423 }
424 case provider.ChunkToolCallArgsDelta:
425 progress = append(progress, chunk.ArgChars)
426 if chunk.ToolCall.ID != "call_1" {
427 t.Errorf("delta ID = %q", chunk.ToolCall.ID)
428 }
429 case provider.ChunkToolCall:
430 completed++
431 if chunk.ToolCall.ID != "call_1" || chunk.ToolCall.Name != "bash" || chunk.ToolCall.Arguments != `{"command":"pwd"}` {
432 t.Errorf("complete = %+v", chunk.ToolCall)
433 }
434 }
435 }
436 if starts != 1 || completed != 1 {
437 t.Fatalf("starts=%d completed=%d", starts, completed)
438 }
439 if len(progress) != 2 || progress[1] <= progress[0] {
440 t.Fatalf("argument progress = %v, want cumulative", progress)
441 }
442 }
443
444 func TestIncompleteResponseSurfacesFinishReason(t *testing.T) {
445 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
446 writeEvents(w, `{"type":"response.incomplete","response":{"id":"resp_1","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}}`)
447 }))
448 defer server.Close()
449 p := New(Config{Name: "test", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateful"}).(*client)
450 p.lastResponseID = "stale"
451 p.expectedPrefixDigest = "stale"
452 chunks := collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
453 for _, chunk := range chunks {
454 if chunk.Type == provider.ChunkUsage {
455 if chunk.Usage.FinishReason != "length" {
456 t.Fatalf("finish reason = %q", chunk.Usage.FinishReason)
457 }
458 if p.lastResponseID != "" || p.expectedPrefixDigest != "" {
459 t.Fatalf("incomplete response retained stateful context: id=%q digest=%q", p.lastResponseID, p.expectedPrefixDigest)
460 }
461 return
462 }
463 }
464 t.Fatal("missing usage chunk")
465 }
466
467 func TestStatefulContinuationValidatesConversationPrefix(t *testing.T) {
468 var mu sync.Mutex
469 var bodies []map[string]any
470 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
471 var body map[string]any
472 _ = json.NewDecoder(r.Body).Decode(&body)
473 mu.Lock()
474 bodies = append(bodies, body)
475 id := len(bodies)
476 mu.Unlock()
477 writeEvents(w,
478 `{"type":"response.output_text.delta","item_id":"msg","delta":"answer"}`,
479 `{"type":"response.completed","response":{"id":"resp_`+string(rune('0'+id))+`","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`,
480 )
481 }))
482 defer server.Close()
483 p := New(Config{Name: "stateful", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateful"})
484
485 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "one"}}})
486 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "one"}, {Role: provider.RoleAssistant, Content: "answer"}, {Role: provider.RoleUser, Content: "two"}}})
487 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleSystem, Content: "different"}, {Role: provider.RoleUser, Content: "new session"}}})
488
489 if bodies[1]["previous_response_id"] != "resp_1" || bodies[1]["input"] != "two" {
490 t.Fatalf("valid continuation = %#v", bodies[1])
491 }
492 if bodies[1]["instructions"] != "sys" {
493 t.Fatalf("valid continuation instructions = %#v, want %q", bodies[1]["instructions"], "sys")
494 }
495 if _, ok := bodies[2]["previous_response_id"]; ok {
496 t.Fatalf("session switch reused previous response: %#v", bodies[2])
497 }
498 if _, ok := bodies[2]["input"].([]any); !ok {
499 t.Fatalf("session switch did not send full input: %#v", bodies[2]["input"])
500 }
501 }
502
503 func TestExpiredPreviousResponseRetriesOnceWithFullHistory(t *testing.T) {
504 var mu sync.Mutex
505 var bodies []map[string]any
506 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
507 var body map[string]any
508 _ = json.NewDecoder(r.Body).Decode(&body)
509 mu.Lock()
510 bodies = append(bodies, body)
511 attempt := len(bodies)
512 mu.Unlock()
513 if attempt == 2 {
514 http.Error(w, `previous_response_id expired`, http.StatusBadRequest)
515 return
516 }
517 id := "resp_1"
518 if attempt == 3 {
519 id = "resp_2"
520 }
521 writeEvents(w,
522 `{"type":"response.output_text.delta","item_id":"msg","delta":"answer"}`,
523 `{"type":"response.completed","response":{"id":"`+id+`","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`,
524 )
525 }))
526 defer server.Close()
527 p := New(Config{Name: "stateful", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateful"})
528 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "one"}}})
529 chunks := collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "one"}, {Role: provider.RoleAssistant, Content: "answer"}, {Role: provider.RoleUser, Content: "two"}}})
530 if len(bodies) != 3 {
531 t.Fatalf("request count = %d, want initial + stale + retry", len(bodies))
532 }
533 if bodies[1]["previous_response_id"] != "resp_1" {
534 t.Fatalf("stale attempt = %#v", bodies[1])
535 }
536 if _, ok := bodies[2]["previous_response_id"]; ok {
537 t.Fatalf("retry still has previous response: %#v", bodies[2])
538 }
539 if _, ok := bodies[2]["input"].([]any); !ok {
540 t.Fatalf("retry input = %#v, want full array", bodies[2]["input"])
541 }
542 var usage *provider.Usage
543 for _, chunk := range chunks {
544 if chunk.Type == provider.ChunkUsage {
545 usage = chunk.Usage
546 }
547 }
548 if usage == nil || usage.RequestCount != 2 {
549 t.Fatalf("retry usage = %+v, want request count 2", usage)
550 }
551 }
552
553 func TestDashScopeCacheHeaderIsVendorScoped(t *testing.T) {
554 var got string
555 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
556 got = r.Header.Get("x-dashscope-session-cache")
557 writeEvents(w, `{"type":"response.completed","response":{"id":"resp","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
558 }))
559 defer server.Close()
560 p := New(Config{Name: "test", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateless", SessionCache: boolPtr(true)}).(*client)
561 p.vendor = "deepseek"
562 p.caps = capabilitiesFor("deepseek")
563 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
564 if got != "" {
565 t.Fatalf("DeepSeek request leaked DashScope header %q", got)
566 }
567 p.vendor = "dashscope"
568 p.caps = capabilitiesFor("dashscope")
569 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
570 if got != "enable" {
571 t.Fatalf("DashScope header = %q", got)
572 }
573 }
574
575 func TestVendorCapabilityTableCoversKnownEndpoints(t *testing.T) {
576 tests := []struct {
577 url, vendor string
578 stateless, reasoning bool
579 ignoresTemp bool
580 singleSegment bool
581 }{
582 {"https://api.deepseek.com", "deepseek", true, true, false, false},
583 {"https://api.xiaomimimo.com/v1", "mimo", true, true, true, true},
584 {"https://dashscope.aliyuncs.com/compatible-mode/v1", "dashscope", false, false, false, false},
585 {"https://example.com/v1", "", false, false, false, false},
586 }
587 for _, test := range tests {
588 vendor := DetectVendor(test.url)
589 if vendor != test.vendor {
590 t.Errorf("DetectVendor(%q) = %q, want %q", test.url, vendor, test.vendor)
591 }
592 caps := capabilitiesFor(vendor)
593 if caps.stateless != test.stateless {
594 t.Errorf("capabilities(%q).stateless = %v, want %v", vendor, caps.stateless, test.stateless)
595 }
596 if caps.toolCallReasoning != test.reasoning {
597 t.Errorf("capabilities(%q).toolCallReasoning = %v, want %v", vendor, caps.toolCallReasoning, test.reasoning)
598 }
599 if caps.ignoresTemperature != test.ignoresTemp {
600 t.Errorf("capabilities(%q).ignoresTemperature = %v, want %v", vendor, caps.ignoresTemperature, test.ignoresTemp)
601 }
602 if caps.singleSegmentReasoning != test.singleSegment {
603 t.Errorf("capabilities(%q).singleSegmentReasoning = %v, want %v", vendor, caps.singleSegmentReasoning, test.singleSegment)
604 }
605 }
606 }
607
608 func TestMiMoOmitsTemperatureFromRequestBody(t *testing.T) {
609 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
610 body, _ := io.ReadAll(r.Body)
611 var reqBody map[string]any
612 if err := json.Unmarshal(body, &reqBody); err != nil {
613 t.Fatalf("decode request body: %v", err)
614 }
615 if _, ok := reqBody["temperature"]; ok {
616 t.Fatalf("MiMo request must not carry temperature (vendor forces 1.0 in thinking mode), got %#v", reqBody["temperature"])
617 }
618 writeEvents(w, `{"type":"response.completed","response":{"id":"resp","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
619 }))
620 defer server.Close()
621
622 temp := 0.3
623 p := New(Config{Name: "mimo", APIKey: "key", BaseURL: server.URL, Model: "mimo-v2.5-pro"}).(*client)
624 p.vendor = "mimo"
625 p.caps = capabilitiesFor("mimo")
626 if !p.caps.ignoresTemperature {
627 t.Fatal("MiMo capability must ignore temperature")
628 }
629 collect(t, p, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}, Temperature: &temp})
630
631 // Control: an unknown endpoint still sends temperature.
632 var gotTemp any
633 control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
634 body, _ := io.ReadAll(r.Body)
635 var reqBody map[string]any
636 _ = json.Unmarshal(body, &reqBody)
637 gotTemp = reqBody["temperature"]
638 writeEvents(w, `{"type":"response.completed","response":{"id":"resp","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
639 }))
640 defer control.Close()
641 p2 := New(Config{Name: "openai", APIKey: "key", BaseURL: control.URL, Model: "m"}).(*client)
642 collect(t, p2, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}, Temperature: &temp})
643 if gotTemp == nil {
644 t.Fatal("unknown endpoint must still send temperature")
645 }
646 }
647
648 func TestRequiresToolCallReasoningForStatelessVendors(t *testing.T) {
649 deepseek := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"})
650 if !provider.RequiresToolCallReasoning(deepseek) {
651 t.Fatal("DeepSeek Responses provider must preserve tool-call reasoning")
652 }
653 mimo := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro"})
654 if !provider.RequiresToolCallReasoning(mimo) {
655 t.Fatal("MiMo Responses provider must preserve tool-call reasoning (documented requirement)")
656 }
657 other := New(Config{Name: "other", BaseURL: "https://example.com", Model: "m"})
658 if provider.RequiresToolCallReasoning(other) {
659 t.Fatal("unknown Responses endpoint unexpectedly requires tool-call reasoning")
660 }
661 }
662
663 func TestMissingToolCallReasoningWarningFingerprintTracksResponsesConfiguration(t *testing.T) {
664 first := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: "high"})
665 same := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com/", Model: "deepseek-v4-flash", Effort: "high"})
666 changedEffort := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: "max"})
667 got := provider.MissingToolCallReasoningWarningFingerprint(first)
668 if got != provider.MissingToolCallReasoningWarningFingerprint(same) {
669 t.Fatal("equivalent Responses configurations produced different fingerprints")
670 }
671 if got == provider.MissingToolCallReasoningWarningFingerprint(changedEffort) {
672 t.Fatal("Responses effort change did not re-key the warning fingerprint")
673 }
674 }
675
676 func TestReasoningIDRoundTripsThroughInput(t *testing.T) { // The OpenAI Responses schema marks Reasoning.id required. A message
677 // carrying a captured ReasoningID must echo it back on the input
678 // reasoning item (and omit it when absent).
679 p := New(Config{Name: "mimo", APIKey: "key", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5"}).(*client)
680 p.vendor = "mimo"
681 p.caps = capabilitiesFor("mimo")
682
683 body, _, _ := p.buildRequestBody(provider.Request{Messages: []provider.Message{
684 {Role: provider.RoleUser, Content: "continue"},
685 {Role: provider.RoleAssistant, ReasoningContent: "think hard", ReasoningID: "rs_item_1", ReasoningStatus: "completed"},
686 }})
687 items := body["input"].([]map[string]any)
688 var reasoningItem map[string]any
689 for _, item := range items {
690 if item["type"] == "reasoning" {
691 reasoningItem = item
692 break
693 }
694 }
695 if reasoningItem == nil {
696 t.Fatal("missing reasoning item in second-turn input")
697 }
698 if reasoningItem["id"] != "rs_item_1" {
699 t.Fatalf("reasoning id = %#v, want rs_item_1", reasoningItem["id"])
700 }
701 if reasoningItem["status"] != "completed" {
702 t.Fatalf("reasoning status = %#v, want completed", reasoningItem["status"])
703 }
704 // content must still be the only other key.
705 if _, has := reasoningItem["summary"]; has {
706 t.Fatalf("mimo must not serialize summary, got %#v", reasoningItem["summary"])
707 }
708
709 // Without a captured id the key stays absent.
710 body2, _, _ := p.buildRequestBody(provider.Request{Messages: []provider.Message{
711 {Role: provider.RoleAssistant, ReasoningContent: "think hard"},
712 }})
713 items2 := body2["input"].([]map[string]any)
714 for _, item := range items2 {
715 if item["type"] == "reasoning" {
716 if _, has := item["id"]; has {
717 t.Fatalf("reasoning id must be omitted when not captured, got %#v", item["id"])
718 }
719 }
720 }
721 }
722
723 func TestWarnOnMissingToolCallReasoningIsModelScoped(t *testing.T) {
724 // DeepSeek pro-tier: warns (endpoint reliably emits tool-call reasoning).
725 pro := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"})
726 if !provider.WarnOnMissingToolCallReasoning(pro) {
727 t.Fatal("DeepSeek pro must warn on missing tool-call reasoning")
728 }
729 // DeepSeek flash-tier: no warning (flash does not emit tool-call reasoning).
730 flash := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"})
731 if provider.WarnOnMissingToolCallReasoning(flash) {
732 t.Fatal("DeepSeek flash must not warn on missing tool-call reasoning")
733 }
734 // MiMo: preserves reasoning on replay but does not guarantee it every
735 // round — a missing chain-of-thought is endpoint-conditional, not a
736 // degradation worth a warning (observed: mimo-v2.5-pro tool-call turn
737 // with empty reasoning).
738 mimo := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro"})
739 if provider.WarnOnMissingToolCallReasoning(mimo) {
740 t.Fatal("MiMo must not warn on missing tool-call reasoning (endpoint-conditional)")
741 }
742 other := New(Config{Name: "other", BaseURL: "https://example.com", Model: "m"})
743 if provider.WarnOnMissingToolCallReasoning(other) {
744 t.Fatal("unknown Responses endpoint must not warn on missing tool-call reasoning")
745 }
746 }
747
748 func TestFailedEventSurfacesAuthenticationError(t *testing.T) {
749 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
750 writeEvents(w, `{"type":"response.failed","response":{"id":"resp","error":{"code":"invalid_api_key","message":"bad API key"}}}`)
751 }))
752 defer server.Close()
753 chunks := collect(t, New(Config{Name: "test", APIKey: "key", KeyEnv: "TEST_API_KEY", BaseURL: server.URL, Model: "m"}), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
754 for _, chunk := range chunks {
755 if chunk.Type == provider.ChunkError {
756 if _, ok := chunk.Err.(*provider.AuthError); !ok || !strings.Contains(chunk.Err.Error(), "TEST_API_KEY") {
757 t.Fatalf("error = %T %v", chunk.Err, chunk.Err)
758 }
759 return
760 }
761 }
762 t.Fatal("missing error chunk")
763 }
764
765 func TestCompletedResponseDefaultsFinishReasonToStop(t *testing.T) {
766 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
767 // No finish_reason anywhere in the completed event: the client must
768 // synthesize FinishReason="stop" for a normally completed response.
769 writeEvents(w, `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}}`)
770 }))
771 defer server.Close()
772 chunks := collect(t, New(Config{Name: "test", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateful"}), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
773 for _, chunk := range chunks {
774 if chunk.Type == provider.ChunkUsage {
775 if chunk.Usage.FinishReason != "stop" {
776 t.Fatalf("finish reason = %q, want \"stop\"", chunk.Usage.FinishReason)
777 }
778 return
779 }
780 }
781 t.Fatal("missing usage chunk")
782 }
783
784 func TestAllZeroUsageCompletedEmitsCompletionSemantics(t *testing.T) {
785 // DashScope 偶发全零 usage(服务端上报缺口)。旧实现无条件抑制——
786 // agent 收不到 usage → reasoningOnlyFinishHonoured 失效 → reasoning-only
787 // 完成被误判触发重试(#7168 评审"完成语义保留"只做了一半)。新语义:
788 // 全零+stop 也发送(计费层 Pricing.Cost 对全零天然 0 成本,不污染统计),
789 // 完成语义恢复;全零且无 finish reason 才抑制(异常前哨场景)。
790 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
791 writeEvents(w, `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
792 }))
793 defer server.Close()
794 chunks := collect(t, New(Config{Name: "test", APIKey: "key", BaseURL: server.URL, Model: "m", Mode: "stateful"}), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
795 var usageChunks int
796 for _, chunk := range chunks {
797 if chunk.Type == provider.ChunkUsage {
798 usageChunks++
799 if chunk.Usage.FinishReason != "stop" {
800 t.Fatalf("all-zero usage must carry FinishReason=stop (completion semantics), got %q", chunk.Usage.FinishReason)
801 }
802 if chunk.Usage.Estimated {
803 t.Fatal("synthesized stop on real (zero) usage must not be marked Estimated")
804 }
805 }
806 }
807 if usageChunks != 1 {
808 t.Fatalf("usage chunks = %d, want 1 (all-zero+stop must be emitted for completion semantics)", usageChunks)
809 }
810 }
811
812 func TestMessagesToInputIncludesSummaryOnReasoningItems(t *testing.T) {
813 // DashScope is the only vendor whose schema requires the summary list;
814 // use its base URL so the capability table opts in.
815 client := New(Config{Name: "dashscope", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", Model: "qwen3"}).(*client)
816 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
817 {Role: provider.RoleUser, Content: "run"},
818 {Role: provider.RoleAssistant, ReasoningContent: "think step by step", Content: "answer"},
819 }})
820 items := body["input"].([]map[string]any)
821 var reasoning map[string]any
822 for _, item := range items {
823 if item["type"] == "reasoning" {
824 reasoning = item
825 break
826 }
827 }
828 if reasoning == nil {
829 t.Fatal("missing reasoning item in input")
830 }
831 // DashScope requires summary as a list, not a scalar; OpenAI format only
832 // needs content. The serialized input must carry both.
833 summary, ok := reasoning["summary"].([]map[string]string)
834 if !ok {
835 t.Fatalf("reasoning summary = %#v, want []map[string]string list", reasoning["summary"])
836 }
837 if len(summary) != 1 || summary[0]["type"] != "summary_text" || summary[0]["text"] != "think step by step" {
838 t.Fatalf("reasoning summary = %#v", summary)
839 }
840 content, ok := reasoning["content"].([]map[string]string)
841 if !ok || len(content) != 1 || content[0]["type"] != "reasoning_text" || content[0]["text"] != "think step by step" {
842 t.Fatalf("reasoning content = %#v", reasoning["content"])
843 }
844 }
845
846 func TestMessagesToInputOmitsSummaryForNonDashScope(t *testing.T) {
847 // MiMo/DeepSeek/unknown endpoints do not define `summary` in their
848 // reasoning-item schema; sending it leaks the reasoning text into an
849 // extra field the server may echo back into the model context (the
850 // chain-of-thought doubling that truncates long tool loops). Only
851 // `content` must be serialized.
852 for _, tc := range []struct {
853 name, baseURL, model string
854 }{
855 {"mimo", "https://api.xiaomimimo.com/v1", "mimo-v2.5"},
856 {"deepseek", "https://api.deepseek.com", "deepseek-v4-flash"},
857 {"unknown", "https://example.com", "m"},
858 } {
859 client := New(Config{Name: tc.name, BaseURL: tc.baseURL, Model: tc.model}).(*client)
860 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
861 {Role: provider.RoleUser, Content: "run"},
862 {Role: provider.RoleAssistant, ReasoningContent: "think step by step", Content: "answer"},
863 }})
864 items := body["input"].([]map[string]any)
865 var reasoning map[string]any
866 for _, item := range items {
867 if item["type"] == "reasoning" {
868 reasoning = item
869 break
870 }
871 }
872 if reasoning == nil {
873 t.Fatalf("%s: missing reasoning item in input", tc.name)
874 }
875 if _, has := reasoning["summary"]; has {
876 t.Fatalf("%s: must not serialize summary (not in vendor schema), got %#v", tc.name, reasoning["summary"])
877 }
878 content, ok := reasoning["content"].([]map[string]string)
879 if !ok || len(content) != 1 || content[0]["type"] != "reasoning_text" || content[0]["text"] != "think step by step" {
880 t.Fatalf("%s: reasoning content = %#v", tc.name, reasoning["content"])
881 }
882 }
883 }
884
885 func TestMessagesToInputOmitsSummaryWithoutReasoning(t *testing.T) {
886 client := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
887 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
888 {Role: provider.RoleUser, Content: "run"},
889 // Assistant turn without reasoning: no reasoning item and thus no
890 // summary list should be serialized at all.
891 {Role: provider.RoleAssistant, Content: "plain answer"},
892 }})
893 items := body["input"].([]map[string]any)
894 for _, item := range items {
895 if item["type"] == "reasoning" {
896 t.Fatalf("unexpected reasoning item without ReasoningContent: %#v", item)
897 }
898 }
899 }
900
901 func TestMessagesToInputTextOnlyStaysStringShape(t *testing.T) {
902 client := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
903 body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{
904 {Role: provider.RoleUser, Content: "hello"},
905 {Role: provider.RoleAssistant, Content: "hi"},
906 }})
907 items := body["input"].([]map[string]any)
908 // Text-only turns keep the documented TextInput string shape even with
909 // vision enabled: images are the only trigger for the array form.
910 for i, item := range items {
911 if _, isArr := item["content"].([]map[string]string); isArr {
912 t.Fatalf("item[%d] content is an array, want string: %#v", i, item)
913 }
914 if _, isArr := item["content"].([]map[string]any); isArr {
915 t.Fatalf("item[%d] content is an array, want string: %#v", i, item)
916 }
917 }
918 }
919
920 func TestMessagesToInputEmbedsImagesAsInputImageParts(t *testing.T) {
921 c := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Extra: map[string]any{"vision": true}}).(*client)
922 body, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{
923 {Role: provider.RoleUser, Content: "what is this", Images: []string{"data:image/png;base64,AAAA", "data:image/jpeg;base64,BBBB"}},
924 }})
925 items := body["input"].([]map[string]any)
926 if len(items) != 1 {
927 t.Fatalf("input items = %d, want 1", len(items))
928 }
929 parts, ok := items[0]["content"].([]map[string]string)
930 if !ok {
931 t.Fatalf("user content = %#v, want InputItemList array (image turn)", items[0]["content"])
932 }
933 if len(parts) != 3 {
934 t.Fatalf("parts = %d, want 3 (text + 2 images)", len(parts))
935 }
936 if parts[0]["type"] != "input_text" || parts[0]["text"] != "what is this" {
937 t.Fatalf("parts[0] = %#v, want input_text", parts[0])
938 }
939 for i, want := range []string{"data:image/png;base64,AAAA", "data:image/jpeg;base64,BBBB"} {
940 if parts[i+1]["type"] != "input_image" || parts[i+1]["image_url"] != want {
941 t.Fatalf("parts[%d] = %#v, want input_image %q", i+1, parts[i+1], want)
942 }
943 }
944 // Vision disabled: images are ignored, content stays a string.
945 plain := New(Config{Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client)
946 body2, _, _ := plain.buildRequestBody(provider.Request{Messages: []provider.Message{
947 {Role: provider.RoleUser, Content: "what is this", Images: []string{"data:image/png;base64,AAAA"}},
948 }})
949 items2 := body2["input"].([]map[string]any)
950 if got, ok := items2[0]["content"].(string); !ok || got != "what is this" {
951 t.Fatalf("vision-off user content = %#v, want string", items2[0]["content"])
952 }
953 }
954
955 func TestResponseFormatJSONObjectOnWire(t *testing.T) {
956 // text.format.type=json_object must be serialized when requested, and
957 // the request body must stay byte-identical without it (cache stability).
958 var gotText any
959 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
960 body, _ := io.ReadAll(r.Body)
961 var reqBody map[string]any
962 _ = json.Unmarshal(body, &reqBody)
963 gotText = reqBody["text"]
964 writeEvents(w, `{"type":"response.completed","response":{"id":"resp","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
965 }))
966 defer server.Close()
967
968 p := New(Config{Name: "mimo", APIKey: "key", BaseURL: server.URL, Model: "mimo-v2.5"}).(*client)
969 p.vendor = "mimo"
970 p.caps = capabilitiesFor("mimo")
971 collect(t, p, provider.Request{
972 Messages: []provider.Message{{Role: provider.RoleUser, Content: "give json"}},
973 ResponseFormat: &provider.ResponseFormat{Type: "json_object"},
974 })
975 want := map[string]any{"format": map[string]any{"type": "json_object"}}
976 if fmt.Sprintf("%v", gotText) != fmt.Sprintf("%v", want) {
977 t.Fatalf("text = %#v, want %#v", gotText, want)
978 }
979
980 // Nil format leaves the wire without the key.
981 plain := New(Config{Name: "openai", APIKey: "key", BaseURL: "https://example.com", Model: "m"}).(*client)
982 req, _, _ := plain.buildRequestBody(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
983 if _, ok := req["text"]; ok {
984 t.Fatalf("text must be omitted without ResponseFormat, got %#v", req["text"])
985 }
986 }
987
988 func TestConversationDigestMirrorsWireKnobs(t *testing.T) {
989 // The stateful fast path compares conversationDigest against the
990 // previous request's wire input. A digest built with different
991 // vision/summary knobs than buildRequestBody would never match,
992 // silently disabling previous_response_id (cache-hit loss).
993 messages := []provider.Message{
994 {Role: provider.RoleSystem, Content: "sys"},
995 {Role: provider.RoleUser, Content: "hi", Images: []string{"https://x/img.png"}},
996 {Role: provider.RoleAssistant, ReasoningContent: "think", Content: "answer"},
997 }
998
999 // Vision on: wire input embeds input_image parts; digest must match.
1000 v := New(Config{Name: "mimo", APIKey: "k", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5", Extra: map[string]any{"vision": true}}).(*client)
1001 v.vendor = "mimo"
1002 v.caps = capabilitiesFor("mimo")
1003 if got := v.conversationDigest(messages); got == "" {
1004 t.Fatal("empty digest")
1005 }
1006 body, _, _ := v.buildRequestBody(provider.Request{Messages: messages})
1007 // Digest of the same messages must equal the digest of the wire input.
1008 // Recompute through a fresh client with identical knobs: identical result.
1009 v2 := New(Config{Name: "mimo", APIKey: "k", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5", Extra: map[string]any{"vision": true}}).(*client)
1010 v2.vendor = "mimo"
1011 v2.caps = capabilitiesFor("mimo")
1012 if v.conversationDigest(messages) != v2.conversationDigest(messages) {
1013 t.Fatal("digest must be deterministic for identical knobs")
1014 }
1015 _ = body
1016
1017 // Vision off vs on must differ (the wire shapes differ).
1018 plain := New(Config{Name: "mimo", APIKey: "k", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5"}).(*client)
1019 plain.vendor = "mimo"
1020 plain.caps = capabilitiesFor("mimo")
1021 if plain.conversationDigest(messages) == v.conversationDigest(messages) {
1022 t.Fatal("vision must change the digest (different wire shape)")
1023 }
1024
1025 // DashScope summary knob: wire includes summary, digest must too.
1026 ds := New(Config{Name: "dashscope", APIKey: "k", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", Model: "qwen3"}).(*client)
1027 ds.caps = capabilitiesFor("dashscope")
1028 if ds.caps.summaryRequired && ds.conversationDigest(messages) == plain.conversationDigest(messages) {
1029 t.Fatal("dashscope summary must change the digest (wire sends summary)")
1030 }
1031 }
1032
1033 // TestSingleSegmentReasoningWiredIntoWarningPolicy:singleSegmentReasoning
1034 // capability 驱动警告策略(评审 #7234 Copilot:wire into behavior)。
1035 func TestSingleSegmentReasoningWiredIntoWarningPolicy(t *testing.T) {
1036 cases := []struct {
1037 name, baseURL, model string
1038 want bool
1039 }{
1040 {"mimo 单段不警告", "https://api.xiaomimimo.com/v1", "mimo-v2.5-pro", false},
1041 {"dashscope 无回传契约不警告", "https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen3", false},
1042 {"deepseek pro 多段警告", "https://api.deepseek.com", "deepseek-v4-pro", true},
1043 {"deepseek flash 豁免", "https://api.deepseek.com", "deepseek-v4-flash", false},
1044 }
1045 for _, tc := range cases {
1046 pro := New(Config{Name: "t", APIKey: "k", BaseURL: tc.baseURL, Model: tc.model}).(interface {
1047 WarnOnMissingToolCallReasoning() bool
1048 })
1049 if got := pro.WarnOnMissingToolCallReasoning(); got != tc.want {
1050 t.Errorf("%s: WarnOnMissingToolCallReasoning = %v, want %v", tc.name, got, tc.want)
1051 }
1052 }
1053 }
1054
1055 // TestFactoryPassesExtraThrough:newFromConfig 原样透传 cfg.Extra——
1056 // vision 开关经 provider factory 后仍生效(评审 #7234 第 3 点)。
1057 func TestFactoryPassesExtraThrough(t *testing.T) {
1058 p, err := newFromConfig(provider.Config{
1059 Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro",
1060 Extra: map[string]any{"vision": true, "effort": "low", "mode": "stateless"},
1061 })
1062 if err != nil {
1063 t.Fatalf("newFromConfig: %v", err)
1064 }
1065 cl := p.(*client)
1066 if !cl.vision {
1067 t.Fatal("vision must survive factory (Extra passthrough)")
1068 }
1069 if cl.effort != "low" {
1070 t.Fatalf("effort = %q, want low", cl.effort)
1071 }
1072 }
1073
1074 // TestReasoningMetaChunkEndToEnd:第一轮 SSE(reasoning item 带 id/status)
1075 // → meta chunk 携带 → 用捕获的 id/status 构造第二轮 Message →
1076 // messagesToInput 回传(评审 #7234 第 1 点要求的端到端回归路径)。
1077 func TestReasoningMetaChunkEndToEnd(t *testing.T) {
1078 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1079 writeEvents(w,
1080 `{"type":"response.output_item.added","item":{"type":"reasoning","id":"rs_round1","summary":[],"content":[]}}`,
1081 `{"type":"response.reasoning_text.delta","item_id":"rs_round1","delta":"think hard"}`,
1082 `{"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_round1","status":"completed"}}`,
1083 `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}}`,
1084 )
1085 }))
1086 defer server.Close()
1087 c := New(Config{Name: "deepseek-responses", APIKey: "key", BaseURL: server.URL, Model: "deepseek-v4-pro"}).(*client)
1088
1089 var rid, rstatus string
1090 ch, _ := c.Stream(context.Background(), provider.Request{
1091 Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
1092 })
1093 for chunk := range ch {
1094 if chunk.Type == provider.ChunkReasoning && chunk.ReasoningID != "" {
1095 rid = chunk.ReasoningID
1096 rstatus = chunk.ReasoningStatus
1097 }
1098 }
1099 if rid != "rs_round1" || rstatus != "completed" {
1100 t.Fatalf("meta chunk id/status = %q/%q, want rs_round1/completed", rid, rstatus)
1101 }
1102
1103 body, _, _ := c.buildRequestBody(provider.Request{
1104 Messages: []provider.Message{
1105 {Role: provider.RoleUser, Content: "hi"},
1106 {Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "think hard",
1107 ReasoningID: rid, ReasoningStatus: rstatus},
1108 },
1109 })
1110 items := body["input"].([]map[string]any)
1111 found := false
1112 for _, item := range items {
1113 if item["type"] == "reasoning" {
1114 if item["id"] != "rs_round1" || item["status"] != "completed" {
1115 t.Fatalf("round-2 reasoning item id/status = %v/%v, want rs_round1/completed",
1116 item["id"], item["status"])
1117 }
1118 found = true
1119 }
1120 }
1121 if !found {
1122 t.Fatal("round-2 input must contain the reasoning item with captured id/status")
1123 }
1124 }
1125
1126 // TestVendorTableMaxOutputTokens:默认输出预算完全由 vendor 表驱动——
1127 // mimo 65536(长思考不截断)、deepseek 32K、unknown 不设。
1128 func TestVendorTableMaxOutputTokens(t *testing.T) {
1129 msg := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
1130
1131 // mimo:表默认 65536(思考模式不设会顶到服务端 32768 截断)
1132 mimo := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro"}).(*client)
1133 body, _, _ := mimo.buildRequestBody(provider.Request{Messages: msg})
1134 if got := body["max_output_tokens"]; got != 65536 {
1135 t.Fatalf("mimo max_output_tokens = %#v, want 65536 (vendor table)", got)
1136 }
1137 // mimo 思考禁用也设 65536(mimo 无 thinking-disabled 豁免——表无条件)
1138 noThinking := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", Effort: "none"}).(*client)
1139 nb, _, _ := noThinking.buildRequestBody(provider.Request{Messages: msg})
1140 if nb["max_output_tokens"] != 65536 {
1141 t.Fatalf("mimo thinking-disabled budget = %#v, want 65536", nb["max_output_tokens"])
1142 }
1143 // deepseek 值来自表(非硬编码常量)
1144 ds := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"}).(*client)
1145 db, _, _ := ds.buildRequestBody(provider.Request{Messages: msg})
1146 if got := db["max_output_tokens"]; got != capabilitiesFor("deepseek").defaultMaxOutputTokens {
1147 t.Fatalf("deepseek budget must come from vendor table, got %#v", got)
1148 }
1149 }
1150
1150 lines GO