| 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 |