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