返回 DeepSeek-Reasonix
provider_presets_test.go
根目录 / internal / config / provider_presets_test.go
1 package config
2
3 import (
4 "strings"
5 "testing"
6
7 "reasonix/internal/provider"
8 "reasonix/internal/provider/openai"
9 )
10
11 func TestAnthropicPresetsDoNotImplicitlyEnableServerTools(t *testing.T) {
12 var entries []ProviderEntry
13 entries = append(entries, Default().Providers...)
14 for _, preset := range CuratedProviderPresets() {
15 entries = append(entries, preset.Entries...)
16 }
17 for _, entry := range entries {
18 if entry.Kind != "anthropic" {
19 continue
20 }
21 root := strings.TrimSuffix(strings.TrimRight(entry.BaseURL, "/"), "/v1")
22 if strings.EqualFold(root, "https://api.anthropic.com") {
23 if entry.Name != "anthropic" || entry.WebSearch == nil || *entry.WebSearch || entry.AuthHeader {
24 t.Fatalf("official Anthropic preset must explicitly use API-key auth without native server tools: %q", entry.Name)
25 }
26 }
27 }
28 }
29
30 func TestCuratedProviderPresetsCoverRequestedProviders(t *testing.T) {
31 wantIDs := []string{
32 "opencode-go-recommended",
33 "longcat-openai",
34 "longcat-anthropic",
35 "token-rhythm",
36 "kimi-cn",
37 "kimi-global",
38 "kimi-coding-plan",
39 "mimo-api",
40 "mimo-anthropic",
41 "mimo-token-plan-cn",
42 "mimo-token-plan-cn-anthropic",
43 "mimo-token-plan-sgp",
44 "mimo-token-plan-sgp-anthropic",
45 "mimo-token-plan-ams",
46 "mimo-token-plan-ams-anthropic",
47 "minimax-cn-api",
48 "minimax-global-api",
49 "minimax-cn-anthropic",
50 "minimax-global-anthropic",
51 "deepseek-responses",
52 "glm-cn",
53 "zai-global",
54 "glm-coding-plan-cn",
55 "glm-coding-plan-cn-anthropic",
56 "zai-coding-plan-global",
57 "zai-coding-plan-global-anthropic",
58 "opencode-go",
59 "opencode-go-anthropic",
60 "opencode-go-responses",
61 "opencode-go-deepseek-anthropic",
62 "opencode-go-deepseek-responses",
63 "opencode-zen-anthropic",
64 "qwen-cn",
65 "qwen-global",
66 "qwen-coding-plan-cn",
67 "qwen-coding-plan-cn-anthropic",
68 "qwen-coding-plan-global",
69 "qwen-coding-plan-global-anthropic",
70 "stepfun",
71 "stepfun-anthropic",
72 "stepfun-responses",
73 "stepfun-api",
74 "stepfun-api-anthropic",
75 "novita",
76 "gmi",
77 "vercel-ai-gateway",
78 "huggingface",
79 "nvidia",
80 "kilocode",
81 "ollama-cloud",
82 "scnet",
83 "scnet-anthropic",
84 }
85 got := map[string]ProviderPreset{}
86 for _, preset := range CuratedProviderPresets() {
87 got[preset.ID] = preset
88 if preset.ID == "" || preset.Label == "" || preset.KeyEnv == "" {
89 t.Fatalf("preset has missing identity fields: %+v", preset)
90 }
91 if len(preset.Entries) == 0 {
92 t.Fatalf("preset %q has no entries", preset.ID)
93 }
94 for _, entry := range preset.Entries {
95 if entry.APIKeyEnv == "" {
96 t.Fatalf("preset %q entry %q has no api_key_env", preset.ID, entry.Name)
97 }
98 if entry.PresetID != preset.ID || entry.PresetVersion != ProviderPresetVersion {
99 t.Fatalf("preset %q entry %q metadata = %q/%d, want %q/%d", preset.ID, entry.Name, entry.PresetID, entry.PresetVersion, preset.ID, ProviderPresetVersion)
100 }
101 var cfg Config
102 if err := cfg.UpsertProvider(entry); err != nil {
103 t.Fatalf("preset %q entry %q failed validation: %v", preset.ID, entry.Name, err)
104 }
105 }
106 }
107 for _, id := range wantIDs {
108 if _, ok := got[id]; !ok {
109 t.Fatalf("missing preset %q", id)
110 }
111 }
112 }
113
114 func TestOpenCodeGoContextWindowPresetsMatchModelsDev(t *testing.T) {
115 preset, ok := CuratedProviderPreset("opencode-go")
116 if !ok || len(preset.Entries) != 1 {
117 t.Fatal("missing opencode-go preset")
118 }
119 entry := preset.Entries[0]
120 if entry.ContextWindow != 128000 {
121 t.Fatalf("provider fallback window = %d, want 128000", entry.ContextWindow)
122 }
123 for id, lim := range provider.OpenCodeGoChatModels() {
124 if got := entry.ModelOverrides[id].ContextWindow; got != lim.Context {
125 t.Fatalf("%s context = %d, want %d", id, got, lim.Context)
126 }
127 }
128 anth, ok := CuratedProviderPreset("opencode-go-anthropic")
129 if !ok {
130 t.Fatal("missing opencode-go-anthropic")
131 }
132 for id, lim := range provider.OpenCodeGoAnthropicModels() {
133 if got := anth.Entries[0].ModelOverrides[id].ContextWindow; got != lim.Context {
134 t.Fatalf("anthropic %s context = %d, want %d", id, got, lim.Context)
135 }
136 }
137 responses, ok := CuratedProviderPreset("opencode-go-responses")
138 if !ok {
139 t.Fatal("missing opencode-go-responses")
140 }
141 for id, lim := range provider.OpenCodeGoResponsesModels() {
142 if got := responses.Entries[0].ModelOverrides[id].ContextWindow; got != lim.Context {
143 t.Fatalf("responses %s context = %d, want %d", id, got, lim.Context)
144 }
145 }
146 dsAnth, _ := CuratedProviderPreset("opencode-go-deepseek-anthropic")
147 if dsAnth.Entries[0].ContextWindow != 1_000_000 {
148 t.Fatalf("deepseek anthropic window = %d", dsAnth.Entries[0].ContextWindow)
149 }
150 dsResp, _ := CuratedProviderPreset("opencode-go-deepseek-responses")
151 if dsResp.Entries[0].ContextWindow != 1_000_000 {
152 t.Fatalf("deepseek responses window = %d", dsResp.Entries[0].ContextWindow)
153 }
154 }
155
156 func TestOpenCodeGoChatPresetsExposeDeepSeekVisionModel(t *testing.T) {
157 for _, presetID := range []string{"opencode-go", "opencode-go-recommended"} {
158 preset, ok := CuratedProviderPreset(presetID)
159 if !ok {
160 t.Fatalf("missing %s preset", presetID)
161 }
162 var entry *ProviderEntry
163 for i := range preset.Entries {
164 if preset.Entries[i].Name == "opencode-go" {
165 entry = &preset.Entries[i]
166 break
167 }
168 }
169 if entry == nil || !entry.HasModel(openai.OfficialDeepSeekVisionModel) || !entry.HasVisionModel(openai.OfficialDeepSeekVisionModel) {
170 t.Fatalf("%s Chat entry = %+v, want DeepSeek vision SKU", presetID, entry)
171 }
172 }
173 }
174
175 func TestOpenCodePresetPresentationMetadata(t *testing.T) {
176 want := map[string]struct {
177 group string
178 section string
179 tier string
180 route string
181 optional bool
182 }{
183 "opencode-go-recommended": {group: "opencode", section: "go", tier: "primary", route: "bundle"},
184 "opencode-go": {group: "opencode", section: "go", tier: "compatibility", route: "chat"},
185 "opencode-go-anthropic": {group: "opencode", section: "go", tier: "advanced", route: "anthropic"},
186 "opencode-go-responses": {group: "opencode", section: "go", tier: "advanced", route: "responses"},
187 "opencode-go-deepseek-anthropic": {group: "opencode", section: "go", tier: "advanced", route: "search-anthropic", optional: true},
188 "opencode-go-deepseek-responses": {group: "opencode", section: "go", tier: "advanced", route: "search-responses", optional: true},
189 "opencode-zen-anthropic": {group: "opencode", section: "zen", tier: "primary", route: "zen-anthropic"},
190 }
191 for id, expected := range want {
192 preset, ok := CuratedProviderPreset(id)
193 if !ok {
194 t.Fatalf("missing preset %q", id)
195 }
196 if preset.DisplayGroup != expected.group || preset.DisplaySection != expected.section || preset.DisplayTier != expected.tier || preset.RouteKind != expected.route || preset.Optional != expected.optional {
197 t.Fatalf("preset %q metadata = group:%q section:%q tier:%q route:%q optional:%t", id, preset.DisplayGroup, preset.DisplaySection, preset.DisplayTier, preset.RouteKind, preset.Optional)
198 }
199 }
200 }
201
202 func TestOpenCodeGoPresetOutputBudgetsAreConservative(t *testing.T) {
203 for _, id := range []string{
204 "opencode-go-recommended",
205 "opencode-go",
206 "opencode-go-anthropic",
207 "opencode-go-responses",
208 "opencode-go-deepseek-anthropic",
209 "opencode-go-deepseek-responses",
210 } {
211 preset, ok := CuratedProviderPreset(id)
212 if !ok {
213 t.Fatalf("missing preset %q", id)
214 }
215 for _, entry := range preset.Entries {
216 if entry.MaxOutputTokens != 32_768 {
217 t.Fatalf("preset %q entry %q max output = %d, want 32768", id, entry.Name, entry.MaxOutputTokens)
218 }
219 }
220 }
221 }
222
223 func TestOpenCodeGoDeepSeekAlternativeProtocolPresets(t *testing.T) {
224 responsesPreset, ok := CuratedProviderPreset("opencode-go-deepseek-responses")
225 if !ok || len(responsesPreset.Entries) != 1 {
226 t.Fatalf("opencode-go-deepseek-responses preset = %+v, found=%v", responsesPreset, ok)
227 }
228 responses := responsesPreset.Entries[0]
229 if responses.Kind != "responses" || responses.BaseURL != "https://opencode.ai/zen/go/v1" || responses.ResponsesMode != "stateless" || responses.Default != "deepseek-v4-flash" {
230 t.Fatalf("opencode-go-deepseek-responses entry = %+v", responses)
231 }
232 if !responses.HasModel("deepseek-v4-flash") || responses.HasModel("deepseek-v4-pro") {
233 t.Fatalf("opencode-go-deepseek-responses models = %v, want currently verified Flash only", responses.ModelList())
234 }
235 if !EffectiveWebSearch(&responses) || !HasServerWebSearchCapability(&responses) {
236 t.Fatalf("opencode-go-deepseek-responses web search = effective:%t capability:%t", EffectiveWebSearch(&responses), HasServerWebSearchCapability(&responses))
237 }
238 if cap := EffortCapabilityForEntry(&responses); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "none") || !containsString(cap.Levels, "low") || !containsString(cap.Levels, "max") {
239 t.Fatalf("opencode-go-deepseek-responses effort capability = %+v", cap)
240 }
241
242 anthropicPreset, ok := CuratedProviderPreset("opencode-go-deepseek-anthropic")
243 if !ok || len(anthropicPreset.Entries) != 1 {
244 t.Fatalf("opencode-go-deepseek-anthropic preset = %+v, found=%v", anthropicPreset, ok)
245 }
246 var cfg Config
247 if err := cfg.UpsertProvider(anthropicPreset.Entries[0]); err != nil {
248 t.Fatalf("UpsertProvider(opencode-go-deepseek-anthropic): %v", err)
249 }
250 flash, ok := cfg.ResolveModel("opencode-go-deepseek-anthropic/deepseek-v4-flash")
251 if !ok {
252 t.Fatal("opencode-go-deepseek-anthropic/deepseek-v4-flash did not resolve")
253 }
254 if cfgEntry, _ := cfg.Provider("opencode-go-deepseek-anthropic"); cfgEntry.HasModel("deepseek-v4-pro") || !EffectiveWebSearch(cfgEntry) || !HasServerWebSearchCapability(cfgEntry) {
255 t.Fatalf("opencode-go-deepseek-anthropic entry = %+v, want Flash-only web search capability", cfgEntry)
256 }
257 if cap := EffortCapabilityForEntry(flash); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "max") {
258 t.Fatalf("opencode-go-deepseek-anthropic Flash effort capability = %+v", cap)
259 }
260 }
261
262 func TestOpenCodeGoResponsesPresetRoutesGrokToResponsesAPI(t *testing.T) {
263 preset, ok := CuratedProviderPreset("opencode-go-responses")
264 if !ok || len(preset.Entries) != 1 {
265 t.Fatalf("opencode-go-responses preset = %+v, found=%v", preset, ok)
266 }
267 entry := preset.Entries[0]
268 if entry.Kind != "responses" || entry.BaseURL != "https://opencode.ai/zen/go/v1" || entry.ResponsesMode != "stateless" || entry.DefaultModel() != "grok-4.5" {
269 t.Fatalf("opencode-go-responses entry = %+v", entry)
270 }
271 for _, model := range []string{"grok-4.5", "gpt-5.6-luna", "muse-spark-1.2-contributor"} {
272 if !entry.HasModel(model) || !entry.HasVisionModel(model) {
273 t.Fatalf("opencode-go-responses model %q missing from model/vision catalog: %+v", model, entry)
274 }
275 }
276 if entry.ContextWindow != 500_000 {
277 t.Fatalf("opencode-go-responses Grok capability = %+v", entry)
278 }
279 for model, want := range map[string][]string{
280 "grok-4.5": {"low", "medium", "high"},
281 "gpt-5.6-luna": {"none", "low", "medium", "high", "xhigh", "max"},
282 "muse-spark-1.2-contributor": {"minimal", "low", "medium", "high", "xhigh"},
283 } {
284 resolved := entry
285 resolved.Model = model
286 resolved.applyModelOverride()
287 cap := EffortCapabilityForEntry(&resolved)
288 if !cap.Supported || cap.Default != "high" {
289 t.Fatalf("opencode-go-responses %s effort capability = %+v", model, cap)
290 }
291 for _, level := range want {
292 if !containsString(cap.Levels, level) {
293 t.Fatalf("opencode-go-responses %s effort levels = %v, missing %s", model, cap.Levels, level)
294 }
295 }
296 }
297 }
298
299 func TestOpenCodeGoRecommendedPresetIsOneClickAndCostBounded(t *testing.T) {
300 preset, ok := CuratedProviderPreset("opencode-go-recommended")
301 if !ok || !preset.Recommended || preset.BillingMode != "subscription_equivalent" {
302 t.Fatalf("recommended preset metadata = %+v, found=%v", preset, ok)
303 }
304 if len(preset.Entries) != 3 {
305 t.Fatalf("recommended preset entries = %d, want 3 route entries", len(preset.Entries))
306 }
307 want := map[string]struct {
308 kind string
309 base string
310 model string
311 window int
312 }{
313 "opencode-go": {kind: "openai", base: "https://opencode.ai/zen/go/v1", model: "glm-5.3", window: 128_000},
314 "opencode-go-anthropic": {kind: "anthropic", base: "https://opencode.ai/zen/go", model: "qwen3.7-plus", window: 262_144},
315 "opencode-go-responses": {kind: "responses", base: "https://opencode.ai/zen/go/v1", model: "grok-4.5", window: 500_000},
316 }
317 for _, entry := range preset.Entries {
318 w, exists := want[entry.Name]
319 if !exists || entry.Kind != w.kind || entry.BaseURL != w.base || entry.DefaultModel() != w.model || entry.ContextWindow != w.window {
320 t.Fatalf("recommended entry = %+v", entry)
321 }
322 if entry.BillingMode != "subscription_equivalent" || entry.MaxOutputTokens != 32_768 {
323 t.Fatalf("recommended entry %q cost controls = billing:%q max_output:%d", entry.Name, entry.BillingMode, entry.MaxOutputTokens)
324 }
325 }
326 }
327
328 func TestDeepSeekAnthropicPresetIsOptionalAndModelScoped(t *testing.T) {
329 preset, ok := CuratedProviderPreset("deepseek-anthropic")
330 if !ok || len(preset.Entries) != 1 {
331 t.Fatalf("DeepSeek Anthropic preset = %+v, want one entry", preset)
332 }
333 entry := preset.Entries[0]
334 if entry.Kind != "anthropic" || entry.BaseURL != deepSeekAnthropicBaseURL || entry.Default != "deepseek-v4-flash" || entry.Thinking != "enabled" || !EffectiveWebSearch(&entry) || entry.Vision || entry.APIKeyEnv != "DEEPSEEK_API_KEY" {
335 t.Fatalf("DeepSeek Anthropic preset entry = %+v", entry)
336 }
337 var cfg Config
338 if err := cfg.UpsertProvider(entry); err != nil {
339 t.Fatalf("UpsertProvider: %v", err)
340 }
341 flash, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-v4-flash")
342 if !ok {
343 t.Fatal("Flash model did not resolve")
344 }
345 pro, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-v4-pro")
346 if !ok {
347 t.Fatal("Pro model did not resolve")
348 }
349 if cap := EffortCapabilityForEntry(flash); cap.Default != "high" || !containsString(cap.Levels, "disabled") || !containsString(cap.Levels, "low") || !containsString(cap.Levels, "max") {
350 t.Fatalf("Flash effort capability = %+v", cap)
351 }
352 if got, err := NormalizeEffort(flash, "low"); err != nil || got != "low" {
353 t.Fatalf("Flash low effort = %q/%v, want low/nil", got, err)
354 }
355 if cap := EffortCapabilityForEntry(pro); cap.Default != "high" || !containsString(cap.Levels, "low") || !containsString(cap.Levels, "max") {
356 t.Fatalf("Pro effort capability = %+v", cap)
357 }
358 }
359
360 func TestDeepSeekResponsesPresetMatchesOfficialSupport(t *testing.T) {
361 preset, ok := CuratedProviderPreset("deepseek-responses")
362 if !ok || len(preset.Entries) != 1 {
363 t.Fatalf("deepseek responses preset = %+v, found=%v", preset, ok)
364 }
365 entry := preset.Entries[0]
366 if entry.Kind != "responses" || entry.BaseURL != "https://api.deepseek.com" || entry.ResponsesMode != "stateless" {
367 t.Fatalf("deepseek responses endpoint = %+v", entry)
368 }
369 if !entry.HasModel("deepseek-v4-flash") || !entry.HasModel("deepseek-v4-pro") || entry.Default != "deepseek-v4-flash" {
370 t.Fatalf("deepseek responses models = %v default=%q", entry.Models, entry.Default)
371 }
372 if entry.ModelsURL != "" {
373 t.Fatalf("deepseek responses models URL = %q, want static supported-model list", entry.ModelsURL)
374 }
375 if !EffectiveWebSearch(&entry) || entry.Vision {
376 t.Fatalf("deepseek responses capabilities = web_search:%t vision:%t vision_models:%v", EffectiveWebSearch(&entry), entry.Vision, entry.VisionModels)
377 }
378 var cfg Config
379 if err := cfg.UpsertProvider(entry); err != nil {
380 t.Fatalf("UpsertProvider: %v", err)
381 }
382 flash, ok := cfg.ResolveModel("deepseek-responses/deepseek-v4-flash")
383 if !ok {
384 t.Fatal("Flash model did not resolve")
385 }
386 pro, ok := cfg.ResolveModel("deepseek-responses/deepseek-v4-pro")
387 if !ok {
388 t.Fatal("Pro model did not resolve")
389 }
390 if cap := EffortCapabilityForEntry(flash); cap.Default != "high" || !containsString(cap.Levels, "disabled") || !containsString(cap.Levels, "low") || !containsString(cap.Levels, "max") {
391 t.Fatalf("Flash effort capability = %+v", cap)
392 }
393 if cap := EffortCapabilityForEntry(pro); cap.Default != "high" || !containsString(cap.Levels, "low") || !containsString(cap.Levels, "max") {
394 t.Fatalf("Pro effort capability = %+v", cap)
395 }
396 }
397
398 func TestCuratedProviderPresetsDisplayOrder(t *testing.T) {
399 wantPrefix := []string{
400 "opencode-go-recommended",
401 "deepseek-responses",
402 "glm-cn",
403 "zai-global",
404 "glm-coding-plan-cn",
405 "glm-coding-plan-cn-anthropic",
406 "zai-coding-plan-global",
407 "zai-coding-plan-global-anthropic",
408 "longcat-openai",
409 "longcat-anthropic",
410 "token-rhythm",
411 "kimi-cn",
412 "kimi-global",
413 "kimi-coding-plan",
414 "minimax-cn-api",
415 "minimax-global-api",
416 "minimax-cn-anthropic",
417 "minimax-global-anthropic",
418 }
419 got := CuratedProviderPresets()
420 if len(got) < len(wantPrefix) {
421 t.Fatalf("got %d presets, want at least %d", len(got), len(wantPrefix))
422 }
423 for i, want := range wantPrefix {
424 if got[i].ID != want {
425 t.Fatalf("preset[%d] = %q, want %q", i, got[i].ID, want)
426 }
427 }
428 }
429
430 func TestCuratedProviderPresetsHideRedundantDeepSeekAnthropicPreset(t *testing.T) {
431 for _, preset := range CuratedProviderPresets() {
432 if preset.ID == "deepseek-anthropic" {
433 t.Fatal("redundant DeepSeek Anthropic preset should not be shown in the curated list")
434 }
435 }
436 if _, ok := CuratedProviderPreset("deepseek-anthropic"); !ok {
437 t.Fatal("legacy DeepSeek Anthropic preset must remain available for compatibility")
438 }
439 }
440
441 func TestCuratedProviderPresetsStepFunUsesOfficialBaseURLs(t *testing.T) {
442 tests := []struct {
443 id string
444 kind string
445 baseURL string
446 }{
447 {
448 id: "stepfun",
449 kind: "openai",
450 baseURL: "https://api.stepfun.com/step_plan/v1",
451 },
452 {
453 id: "stepfun-anthropic",
454 kind: "anthropic",
455 baseURL: "https://api.stepfun.com/step_plan",
456 },
457 {
458 id: "stepfun-responses",
459 kind: "responses",
460 baseURL: "https://api.stepfun.com/v1",
461 },
462 {
463 id: "stepfun-api",
464 kind: "openai",
465 baseURL: "https://api.stepfun.com/v1",
466 },
467 {
468 id: "stepfun-api-anthropic",
469 kind: "anthropic",
470 baseURL: "https://api.stepfun.com",
471 },
472 }
473 for _, tt := range tests {
474 t.Run(tt.id, func(t *testing.T) {
475 preset, ok := CuratedProviderPreset(tt.id)
476 if !ok {
477 t.Fatalf("missing preset %q", tt.id)
478 }
479 if len(preset.Entries) != 1 {
480 t.Fatalf("preset %q has %d entries, want 1", tt.id, len(preset.Entries))
481 }
482 entry := preset.Entries[0]
483 if entry.Kind != tt.kind {
484 t.Fatalf("preset %q kind = %q, want %q", tt.id, entry.Kind, tt.kind)
485 }
486 if entry.BaseURL != tt.baseURL {
487 t.Fatalf("preset %q base_url = %q, want %q", tt.id, entry.BaseURL, tt.baseURL)
488 }
489 })
490 }
491 }
492
493 // StepFun's Responses API enables only step-3.7-flash server-side and ignores
494 // previous_response_id, so the preset must ship the single-model catalog and
495 // the stateless mode.
496 func TestCuratedProviderPresetsStepFunResponsesContract(t *testing.T) {
497 preset, ok := CuratedProviderPreset("stepfun-responses")
498 if !ok || len(preset.Entries) != 1 {
499 t.Fatal("missing stepfun-responses preset")
500 }
501 entry := preset.Entries[0]
502 if got := entry.ModelList(); len(got) != 1 || got[0] != "step-3.7-flash" {
503 t.Fatalf("stepfun-responses models = %v, want [step-3.7-flash]", got)
504 }
505 if entry.ResponsesMode != "stateless" {
506 t.Fatalf("stepfun-responses responses_mode = %q, want stateless", entry.ResponsesMode)
507 }
508 }
509
510 // The pay-as-you-go presets must stay on the standard /v1 surface and expose
511 // vision only on step-3.7-flash, matching the channel's live behavior (the
512 // step_plan channel rejects images; 3.5 SKUs are not Responses-enabled).
513 func TestCuratedProviderPresetsStepFunPayAsYouGoContract(t *testing.T) {
514 api, ok := CuratedProviderPreset("stepfun-api")
515 if !ok || len(api.Entries) != 1 {
516 t.Fatal("missing stepfun-api preset")
517 }
518 entry := api.Entries[0]
519 if !stringSlicesEqual(entry.VisionModels, []string{"step-3.7-flash"}) {
520 t.Fatalf("stepfun-api vision_models = %v, want [step-3.7-flash]", entry.VisionModels)
521 }
522 anthropic, ok := CuratedProviderPreset("stepfun-api-anthropic")
523 if !ok || len(anthropic.Entries) != 1 {
524 t.Fatal("missing stepfun-api-anthropic preset")
525 }
526 if got := anthropic.Entries[0].BaseURL; got != "https://api.stepfun.com" {
527 t.Fatalf("stepfun-api-anthropic base_url = %q, want origin (SDK appends /v1/messages)", got)
528 }
529 }
530
531 func TestCuratedProviderPresetReturnsDeepCopy(t *testing.T) {
532 preset, ok := CuratedProviderPreset("minimax-cn-api")
533 if !ok {
534 t.Fatal("missing minimax-cn-api preset")
535 }
536 preset.Entries[0].Models[0] = "mutated"
537 preset.Entries[0].ExtraBody["reasoning_split"] = false
538 preset.Entries[0].PresetID = "mutated"
539
540 fresh, ok := CuratedProviderPreset("minimax-cn-api")
541 if !ok {
542 t.Fatal("missing fresh minimax-cn-api preset")
543 }
544 if got := fresh.Entries[0].Models[0]; got != "MiniMax-M3" {
545 t.Fatalf("fresh minimax first model = %q, want MiniMax-M3", got)
546 }
547 if got, _ := fresh.Entries[0].ExtraBody["reasoning_split"].(bool); !got {
548 t.Fatalf("fresh minimax reasoning_split = %v, want true", fresh.Entries[0].ExtraBody["reasoning_split"])
549 }
550 if got := fresh.Entries[0].PresetID; got != "minimax-cn-api" {
551 t.Fatalf("fresh minimax preset_id = %q, want minimax-cn-api", got)
552 }
553
554 qwen, ok := CuratedProviderPreset("qwen-cn")
555 if !ok {
556 t.Fatal("missing qwen-cn preset")
557 }
558 qwen.Entries[0].ModelOverrides["glm-5"] = ProviderModelOverride{ContextWindow: 1}
559 freshQwen, ok := CuratedProviderPreset("qwen-cn")
560 if !ok {
561 t.Fatal("missing fresh qwen-cn preset")
562 }
563 if got := freshQwen.Entries[0].ModelOverrides["glm-5"].ContextWindow; got != 202_752 {
564 t.Fatalf("fresh qwen glm-5 context window = %d, want 202752", got)
565 }
566 }
567
568 func TestCuratedProviderPresetCapabilities(t *testing.T) {
569 var cfg Config
570 for _, preset := range CuratedProviderPresets() {
571 for _, entry := range preset.Entries {
572 if err := cfg.UpsertProvider(entry); err != nil {
573 t.Fatalf("upsert preset %q: %v", preset.ID, err)
574 }
575 }
576 }
577
578 kimiCN, ok := cfg.Provider("kimi-cn")
579 if !ok {
580 t.Fatal("kimi-cn provider missing")
581 }
582 if kimiCN.DefaultModel() != "kimi-k2.7-code" || !kimiCN.HasVisionModel("kimi-k2.7-code-highspeed") || kimiCN.BalanceURL == "" {
583 t.Fatalf("kimi-cn capability mismatch: %+v", kimiCN)
584 }
585 kimiCNK3, ok := cfg.ResolveModel("kimi-cn/kimi-k3")
586 if !ok {
587 t.Fatal("kimi-cn/kimi-k3 did not resolve")
588 }
589 if !EffectiveVision(kimiCNK3) || kimiCNK3.ContextWindow != 1_048_576 ||
590 ReasoningProtocolForEntry(kimiCNK3) != ReasoningProtocolOpenAI ||
591 !stringSlicesEqual(kimiCNK3.SupportedEfforts, []string{"low", "high", "max"}) ||
592 EffectiveEffort(kimiCNK3) != "max" {
593 t.Fatalf("kimi-cn/kimi-k3 capability mismatch: %+v", kimiCNK3)
594 }
595 kimiCNK27, ok := cfg.ResolveModel("kimi-cn/kimi-k2.7-code")
596 if !ok || ReasoningProtocolForEntry(kimiCNK27) != ReasoningProtocolNone {
597 t.Fatalf("kimi-cn K2.7 reasoning protocol changed: %+v", kimiCNK27)
598 }
599 kimiGlobal, ok := cfg.Provider("kimi-global")
600 if !ok {
601 t.Fatal("kimi-global provider missing")
602 }
603 if kimiGlobal.BaseURL != "https://api.moonshot.ai/v1" || kimiGlobal.APIKeyEnv != "MOONSHOT_API_KEY" {
604 t.Fatalf("kimi-global endpoint/key mismatch: %+v", kimiGlobal)
605 }
606 kimiGlobalK3, ok := cfg.ResolveModel("kimi-global/kimi-k3")
607 if !ok || !EffectiveVision(kimiGlobalK3) || kimiGlobalK3.ContextWindow != 1_048_576 || EffectiveEffort(kimiGlobalK3) != "max" {
608 t.Fatalf("kimi-global/kimi-k3 capability mismatch: %+v", kimiGlobalK3)
609 }
610 kimiPlan, ok := cfg.Provider("kimi-coding-plan")
611 if !ok {
612 t.Fatal("kimi-coding-plan provider missing")
613 }
614 if kimiPlan.Kind != "anthropic" || kimiPlan.DefaultModel() != "kimi-for-coding" || !kimiPlan.HasVisionModel("kimi-for-coding") || kimiPlan.Thinking != "adaptive" || kimiPlan.HasModel("kimi-code") {
615 t.Fatalf("kimi-coding-plan capability mismatch: %+v", kimiPlan)
616 }
617
618 longcat, ok := cfg.ResolveModel("longcat-openai/LongCat-2.0")
619 if !ok {
620 t.Fatal("longcat-openai/LongCat-2.0 did not resolve")
621 }
622 if longcat.BaseURL != "https://api.longcat.chat/openai/v1" || longcat.ModelsURL != "https://api.longcat.chat/openai/v1/models" || longcat.APIKeyEnv != "LONGCAT_API_KEY" {
623 t.Fatalf("longcat-openai endpoint/key mismatch: %+v", longcat)
624 }
625 if longcat.ContextWindow != longCat20ContextWindow {
626 t.Fatalf("longcat-openai context_window = %d, want %d", longcat.ContextWindow, longCat20ContextWindow)
627 }
628 if cap := EffortCapabilityForEntry(longcat); !cap.Supported || cap.Default != "enabled" || !containsString(cap.Levels, "disabled") {
629 t.Fatalf("longcat-openai effort capability = %+v, want enabled/disabled", cap)
630 }
631 if price := longcat.PriceForModel("LongCat-2.0"); price == nil || price.Currency != "¥" || price.Input != 2 || price.Output != 8 || price.CacheHit != 0.04 {
632 t.Fatalf("LongCat-2.0 price = %+v, want RMB discounted pricing", price)
633 }
634 longcatAnthropic, ok := cfg.Provider("longcat-anthropic")
635 if !ok {
636 t.Fatal("longcat-anthropic provider missing")
637 }
638 if longcatAnthropic.Kind != "anthropic" || longcatAnthropic.BaseURL != "https://api.longcat.chat/anthropic" || longcatAnthropic.ModelsURL != "https://api.longcat.chat/anthropic/v1/models" || !longcatAnthropic.AuthHeader || longcatAnthropic.Thinking != "enabled" {
639 t.Fatalf("longcat-anthropic capability mismatch: %+v", longcatAnthropic)
640 }
641 if longcatAnthropic.ContextWindow != longCat20ContextWindow {
642 t.Fatalf("longcat-anthropic context_window = %d, want %d", longcatAnthropic.ContextWindow, longCat20ContextWindow)
643 }
644
645 mimo, ok := cfg.Provider("mimo-api")
646 if !ok {
647 t.Fatal("mimo-api provider missing")
648 }
649 if !mimo.NoProxy {
650 t.Fatal("mimo-api preset should bypass configured proxy for China-only endpoint")
651 }
652 if mimo.DefaultModel() != "mimo-v2.5-pro" || !mimo.HasVisionModel("mimo-v2.5") || mimo.HasVisionModel("mimo-v2.5-pro") {
653 t.Fatalf("mimo vision capability mismatch: %+v", mimo.VisionModels)
654 }
655 if price := mimo.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
656 t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
657 }
658 mimoAnthropic, ok := cfg.Provider("mimo-anthropic")
659 if !ok {
660 t.Fatal("mimo-anthropic provider missing")
661 }
662 if mimoAnthropic.Kind != "anthropic" || mimoAnthropic.BaseURL != "https://api.xiaomimimo.com/anthropic" || mimoAnthropic.Thinking != "adaptive" {
663 t.Fatalf("mimo-anthropic capability mismatch: %+v", mimoAnthropic)
664 }
665 mimoPlan, ok := cfg.Provider("mimo-token-plan-cn")
666 if !ok {
667 t.Fatal("mimo-token-plan-cn provider missing")
668 }
669 if !mimoPlan.NoProxy || mimoPlan.APIKeyEnv != "MIMO_TOKEN_PLAN_API_KEY" || !mimoPlan.HasVisionModel("mimo-v2.5") {
670 t.Fatalf("mimo-token-plan-cn capability mismatch: %+v", mimoPlan)
671 }
672 mimoSGP, ok := cfg.Provider("mimo-token-plan-sgp")
673 if !ok {
674 t.Fatal("mimo-token-plan-sgp provider missing")
675 }
676 if mimoSGP.NoProxy || mimoSGP.BaseURL != "https://token-plan-sgp.xiaomimimo.com/v1" {
677 t.Fatalf("mimo-token-plan-sgp endpoint/proxy mismatch: %+v", mimoSGP)
678 }
679 mimoPlanAnthropic, ok := cfg.Provider("mimo-token-plan-cn-anthropic")
680 if !ok {
681 t.Fatal("mimo-token-plan-cn-anthropic provider missing")
682 }
683 if mimoPlanAnthropic.Kind != "anthropic" || !mimoPlanAnthropic.NoProxy || mimoPlanAnthropic.BaseURL != "https://token-plan-cn.xiaomimimo.com/anthropic" {
684 t.Fatalf("mimo-token-plan-cn-anthropic capability mismatch: %+v", mimoPlanAnthropic)
685 }
686
687 minimax, ok := cfg.ResolveModel("minimax-cn-api/MiniMax-M3")
688 if !ok {
689 t.Fatal("minimax-cn-api/MiniMax-M3 did not resolve")
690 }
691 if cap := EffortCapabilityForEntry(minimax); !cap.Supported || cap.Default != "adaptive" || !containsString(cap.Levels, "disabled") {
692 t.Fatalf("minimax effort capability = %+v, want adaptive/disabled", cap)
693 }
694 minimaxGlobalAPI, ok := cfg.Provider("minimax-global-api")
695 if !ok {
696 t.Fatal("minimax-global-api provider missing")
697 }
698 if minimaxGlobalAPI.BaseURL != "https://api.minimax.io/v1" || !minimaxGlobalAPI.HasModel("MiniMax-M2.7-highspeed") {
699 t.Fatalf("minimax-global-api capability mismatch: %+v", minimaxGlobalAPI)
700 }
701 minimaxPlan, ok := cfg.Provider("minimax-cn-anthropic")
702 if !ok {
703 t.Fatal("minimax-cn-anthropic provider missing")
704 }
705 if minimaxPlan.Kind != "anthropic" || !minimaxPlan.AuthHeader || !minimaxPlan.HasVisionModel("MiniMax-M3") || !minimaxPlan.HasModel("MiniMax-M2.7-highspeed") {
706 t.Fatalf("minimax-cn-anthropic capability mismatch: %+v", minimaxPlan)
707 }
708 minimaxGlobal, ok := cfg.Provider("minimax-global-anthropic")
709 if !ok {
710 t.Fatal("minimax-global-anthropic provider missing")
711 }
712 if minimaxGlobal.Kind != "anthropic" || !minimaxGlobal.AuthHeader || minimaxGlobal.BaseURL != "https://api.minimax.io/anthropic" {
713 t.Fatalf("minimax-global-anthropic capability mismatch: %+v", minimaxGlobal)
714 }
715
716 glm, ok := cfg.ResolveModel("glm-cn/glm-5.2")
717 if !ok {
718 t.Fatal("glm-cn/glm-5.2 did not resolve")
719 }
720 if cap := EffortCapabilityForEntry(glm); !cap.Supported || cap.Default != "enabled" || !containsString(cap.Levels, "disabled") {
721 t.Fatalf("glm effort capability = %+v, want enabled/disabled", cap)
722 }
723 if !glm.HasVisionModel("glm-5v-turbo") {
724 t.Fatalf("glm vision capability mismatch: %+v", glm.VisionModels)
725 }
726 zaiGlobal, ok := cfg.ResolveModel("zai-global/glm-5.2")
727 if !ok {
728 t.Fatal("zai-global/glm-5.2 did not resolve")
729 }
730 if cap := EffortCapabilityForEntry(zaiGlobal); !cap.Supported || cap.Default != "enabled" {
731 t.Fatalf("zai-global effort capability = %+v, want enabled", cap)
732 }
733 glmPlanCN, ok := cfg.Provider("glm-coding-plan-cn")
734 if !ok {
735 t.Fatal("glm-coding-plan-cn provider missing")
736 }
737 if !glmPlanCN.NoProxy || glmPlanCN.DefaultModel() != "glm-5.2" || glmPlanCN.ContextWindow != 1000000 {
738 t.Fatalf("glm-coding-plan-cn capability mismatch: %+v", glmPlanCN)
739 }
740 glmPlanAnthropic, ok := cfg.Provider("glm-coding-plan-cn-anthropic")
741 if !ok {
742 t.Fatal("glm-coding-plan-cn-anthropic provider missing")
743 }
744 if glmPlanAnthropic.Kind != "anthropic" || !glmPlanAnthropic.AuthHeader || glmPlanAnthropic.DefaultModel() != "glm-5.2" || glmPlanAnthropic.ContextWindow != 1000000 {
745 t.Fatalf("glm-coding-plan-cn-anthropic capability mismatch: %+v", glmPlanAnthropic)
746 }
747 zaiPlanGlobal, ok := cfg.Provider("zai-coding-plan-global")
748 if !ok {
749 t.Fatal("zai-coding-plan-global provider missing")
750 }
751 if zaiPlanGlobal.NoProxy || zaiPlanGlobal.BaseURL != "https://api.z.ai/api/coding/paas/v4" || zaiPlanGlobal.DefaultModel() != "glm-5.2" {
752 t.Fatalf("zai-coding-plan-global capability mismatch: %+v", zaiPlanGlobal)
753 }
754 zaiPlanAnthropic, ok := cfg.Provider("zai-coding-plan-global-anthropic")
755 if !ok {
756 t.Fatal("zai-coding-plan-global-anthropic provider missing")
757 }
758 if zaiPlanAnthropic.Kind != "anthropic" || !zaiPlanAnthropic.AuthHeader || zaiPlanAnthropic.BaseURL != "https://api.z.ai/api/anthropic" || zaiPlanAnthropic.DefaultModel() != "glm-5.2" || zaiPlanAnthropic.ContextWindow != 1000000 {
759 t.Fatalf("zai-coding-plan-global-anthropic capability mismatch: %+v", zaiPlanAnthropic)
760 }
761
762 deepseek, ok := cfg.ResolveModel("opencode-go/deepseek-v4-pro")
763 if !ok {
764 t.Fatal("opencode-go/deepseek-v4-pro did not resolve")
765 }
766 if protocol := ReasoningProtocolForEntry(deepseek); protocol != ReasoningProtocolDeepSeek {
767 t.Fatalf("opencode deepseek protocol = %q, want deepseek", protocol)
768 }
769 if cap := EffortCapabilityForEntry(deepseek); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "max") {
770 t.Fatalf("opencode deepseek effort capability = %+v, want high/max", cap)
771 }
772
773 kimi, ok := cfg.ResolveModel("opencode-go/kimi-k2.6")
774 if !ok {
775 t.Fatal("opencode-go/kimi-k2.6 did not resolve")
776 }
777 if protocol := ReasoningProtocolForEntry(kimi); protocol != ReasoningProtocolOpenAI {
778 t.Fatalf("opencode kimi protocol = %q, want openai", protocol)
779 }
780 if cap := EffortCapabilityForEntry(kimi); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "medium") {
781 t.Fatalf("opencode kimi effort capability = %+v, want low/medium/high", cap)
782 }
783 kimiK3, ok := cfg.ResolveModel("opencode-go/kimi-k3")
784 if !ok {
785 t.Fatal("opencode-go/kimi-k3 did not resolve")
786 }
787 if protocol := ReasoningProtocolForEntry(kimiK3); protocol != ReasoningProtocolOpenAI {
788 t.Fatalf("opencode Kimi K3 protocol = %q, want openai", protocol)
789 }
790 if cap := EffortCapabilityForEntry(kimiK3); !cap.Supported || cap.Default != "max" || !containsString(cap.Levels, "high") || !containsString(cap.Levels, "max") {
791 t.Fatalf("opencode Kimi K3 effort capability = %+v, want high/max", cap)
792 }
793 for _, level := range []string{"high", "max"} {
794 if got, err := NormalizeEffort(kimiK3, level); err != nil || got != level {
795 t.Fatalf("opencode Kimi K3 /effort %s = %q, %v; want %s", level, got, err, level)
796 }
797 }
798 if kimiK3.ContextWindow != 1_048_576 || !EffectiveVision(kimiK3) {
799 t.Fatalf("opencode Kimi K3 context/vision capability mismatch: %+v", kimiK3)
800 }
801
802 plain, ok := cfg.ResolveModel("opencode-go/glm-5.2")
803 if !ok {
804 t.Fatal("opencode-go/glm-5.2 did not resolve")
805 }
806 if cap := EffortCapabilityForEntry(plain); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "max") {
807 t.Fatalf("opencode GLM-5.2 effort capability = %+v, want high/max", cap)
808 }
809 zen, ok := cfg.Provider("opencode-zen-anthropic")
810 if !ok {
811 t.Fatal("opencode-zen-anthropic provider missing")
812 }
813 if zen.Kind != "anthropic" || zen.BaseURL != "https://opencode.ai/zen" || !zen.HasModel("qwen3.6-plus") {
814 t.Fatalf("opencode-zen-anthropic capability mismatch: %+v", zen)
815 }
816 goAnthropic, ok := cfg.Provider("opencode-go-anthropic")
817 if !ok {
818 t.Fatal("opencode-go-anthropic provider missing")
819 }
820 if goAnthropic.Kind != "anthropic" || goAnthropic.BaseURL != "https://opencode.ai/zen/go" || goAnthropic.DefaultModel() != "qwen3.7-plus" || !goAnthropic.HasModel("qwen3.8-max") || !goAnthropic.HasModel("minimax-m3") || goAnthropic.HasModel("deepseek-v4-flash") {
821 t.Fatalf("opencode-go-anthropic capability mismatch: %+v", goAnthropic)
822 }
823
824 qwenCN, ok := cfg.Provider("qwen-cn")
825 if !ok {
826 t.Fatal("qwen-cn provider missing")
827 }
828 if !qwenCN.NoProxy || qwenCN.DefaultModel() != "qwen3.7-plus" || !qwenCN.HasVisionModel("qwen3.7-plus") || !qwenCN.HasModel("qwen3.7-max") {
829 t.Fatalf("qwen-cn capability mismatch: %+v", qwenCN)
830 }
831
832 qwenGlobal, ok := cfg.Provider("qwen-global")
833 if !ok {
834 t.Fatal("qwen-global provider missing")
835 }
836 if qwenGlobal.NoProxy || qwenGlobal.BaseURL != "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" {
837 t.Fatalf("qwen-global endpoint/proxy mismatch: %+v", qwenGlobal)
838 }
839 qwenPlanCN, ok := cfg.Provider("qwen-coding-plan-cn")
840 if !ok {
841 t.Fatal("qwen-coding-plan-cn provider missing")
842 }
843 if !qwenPlanCN.NoProxy || !qwenPlanCN.HasModel("qwen3.6-plus") || !qwenPlanCN.HasVisionModel("qwen3.7-plus") || qwenPlanCN.HasVisionModel("qwen3-coder-plus") {
844 t.Fatalf("qwen-coding-plan-cn capability mismatch: %+v", qwenPlanCN)
845 }
846 qwenPlanCNAnthropic, ok := cfg.Provider("qwen-coding-plan-cn-anthropic")
847 if !ok {
848 t.Fatal("qwen-coding-plan-cn-anthropic provider missing")
849 }
850 if qwenPlanCNAnthropic.Kind != "anthropic" || !qwenPlanCNAnthropic.NoProxy || qwenPlanCNAnthropic.BaseURL != "https://coding.dashscope.aliyuncs.com/apps/anthropic" {
851 t.Fatalf("qwen-coding-plan-cn-anthropic capability mismatch: %+v", qwenPlanCNAnthropic)
852 }
853 qwenPlanGlobal, ok := cfg.Provider("qwen-coding-plan-global")
854 if !ok {
855 t.Fatal("qwen-coding-plan-global provider missing")
856 }
857 if qwenPlanGlobal.NoProxy || !qwenPlanGlobal.HasModel("qwen3.6-plus") || qwenPlanGlobal.BaseURL != "https://coding-intl.dashscope.aliyuncs.com/v1" {
858 t.Fatalf("qwen-coding-plan-global capability mismatch: %+v", qwenPlanGlobal)
859 }
860 qwenProviders := []string{
861 "qwen-cn",
862 "qwen-global",
863 "qwen-coding-plan-cn",
864 "qwen-coding-plan-cn-anthropic",
865 "qwen-coding-plan-global",
866 "qwen-coding-plan-global-anthropic",
867 }
868 qwenContextWindows := map[string]int{
869 "qwen3.7-plus": 1_000_000,
870 "qwen3-coder-plus": 1_000_000,
871 "qwen3-max-2026-01-23": 262_144,
872 "qwen3-coder-next": 262_144,
873 "MiniMax-M2.5": 196_608,
874 "glm-5": 202_752,
875 "glm-4.7": 202_752,
876 "kimi-k2.5": 262_144,
877 }
878 for _, providerID := range qwenProviders {
879 for model, want := range qwenContextWindows {
880 resolved, ok := cfg.ResolveModel(providerID + "/" + model)
881 if !ok {
882 t.Fatalf("resolve %s/%s failed", providerID, model)
883 }
884 if got := resolved.ContextWindow; got != want {
885 t.Fatalf("%s/%s context window = %d, want %d", providerID, model, got, want)
886 }
887 }
888 }
889
890 gmi, ok := cfg.Provider("gmi")
891 if !ok {
892 t.Fatal("gmi provider missing")
893 }
894 if got := gmi.Headers["User-Agent"]; got != "Reasonix" {
895 t.Fatalf("gmi User-Agent header = %q, want Reasonix", got)
896 }
897 vercel, ok := cfg.Provider("vercel-ai-gateway")
898 if !ok {
899 t.Fatal("vercel-ai-gateway provider missing")
900 }
901 if vercel.Kind != "anthropic" || !vercel.AuthHeader || vercel.DefaultModel() != "anthropic/claude-sonnet-4.6" || !vercel.HasModel("moonshotai/kimi-k2.7-code") {
902 t.Fatalf("vercel-ai-gateway capability mismatch: %+v", vercel)
903 }
904
905 ollama, ok := cfg.ResolveModel("ollama-cloud/nemotron-3-nano:30b")
906 if !ok {
907 t.Fatal("ollama-cloud/nemotron-3-nano:30b did not resolve")
908 }
909 if cap := EffortCapabilityForEntry(ollama); !cap.Supported || cap.Default != "auto" || !containsString(cap.Levels, "max") || !containsString(cap.Levels, "none") {
910 t.Fatalf("ollama-cloud effort capability = %+v, want none/max", cap)
911 }
912 }
913
913 lines GO