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