返回 DeepSeek-Reasonix
backfill_test.go
根目录 / internal / config / backfill_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "reflect"
7 "slices"
8 "strings"
9 "testing"
10
11 "github.com/BurntSushi/toml"
12
13 "reasonix/internal/provider"
14 "reasonix/internal/provider/openai"
15 )
16
17 func hasModel(c *Config, model string) *ProviderEntry {
18 for i := range c.Providers {
19 if slices.Contains(c.Providers[i].ModelList(), model) {
20 return &c.Providers[i]
21 }
22 }
23 return nil
24 }
25
26 func TestBackfillDeepSeekProRestoresPro(t *testing.T) {
27 c := &Config{Providers: []ProviderEntry{
28 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
29 }}
30 backfillDeepSeekPro(c)
31 pro := hasModel(c, "deepseek-v4-pro")
32 if pro == nil {
33 t.Fatal("deepseek-v4-pro not restored")
34 } else if pro.Price == nil || pro.Price.Output != 3.96 || pro.Price.Currency != "$" {
35 t.Errorf("pro price = %+v, want default USD preset", pro.Price)
36 }
37 }
38
39 func TestBackfillDeepSeekProUsesConfiguredLanguage(t *testing.T) {
40 // Language no longer selects list-price tables; backfill uses USD official
41 // defaults unless the sibling provider freezes a billing_currency.
42 c := &Config{Language: "zh", Providers: []ProviderEntry{
43 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BillingCurrency: "CNY", Price: deepSeekV4FlashPriceCNY()},
44 }}
45 backfillDeepSeekPro(c)
46 pro := hasModel(c, "deepseek-v4-pro")
47 if pro == nil {
48 t.Fatal("deepseek-v4-pro not restored")
49 }
50 // Pro inherits from template using DeepSeekOfficialPricingCurrency (sibling billing).
51 if pro.Price == nil {
52 t.Fatal("pro price missing")
53 }
54 }
55
56 func TestBackfillDeepSeekProInheritsKeyEnv(t *testing.T) {
57 c := &Config{Providers: []ProviderEntry{
58 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "MY_DS_KEY"},
59 }}
60 backfillDeepSeekPro(c)
61 if pro := hasModel(c, "deepseek-v4-pro"); pro == nil || pro.APIKeyEnv != "MY_DS_KEY" {
62 t.Errorf("pro should inherit the flash key env, got %+v", pro)
63 }
64 }
65
66 func TestBackfillDeepSeekProNoopWhenProPresent(t *testing.T) {
67 c := &Config{Providers: []ProviderEntry{
68 {Name: "deepseek-flash", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"},
69 {Name: "deepseek-pro", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"},
70 }}
71 backfillDeepSeekPro(c)
72 if n := len(c.Providers); n != 2 {
73 t.Errorf("providers grew to %d; should be a no-op when pro is present", n)
74 }
75 }
76
77 func TestBackfillDeepSeekProSkipsCustomEndpoint(t *testing.T) {
78 c := &Config{Providers: []ProviderEntry{
79 {Name: "myproxy", BaseURL: "https://proxy.example.com/v1", Model: "deepseek-v4-flash"},
80 }}
81 backfillDeepSeekPro(c)
82 if hasModel(c, "deepseek-v4-pro") != nil {
83 t.Error("must not add pro for a non-official endpoint that may not serve it")
84 }
85 }
86
87 func TestBackfillDeepSeekProSkipsNonDeepSeek(t *testing.T) {
88 c := &Config{Providers: []ProviderEntry{
89 {Name: "mimo-flash", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5"},
90 }}
91 backfillDeepSeekPro(c)
92 if len(c.Providers) != 1 {
93 t.Error("unrelated config must be untouched")
94 }
95 }
96
97 func TestNormalizeLegacyProviderModelsRepairsOfficialProvider(t *testing.T) {
98 c := &Config{Providers: []ProviderEntry{{
99 Name: "deepseek-flash",
100 Kind: "openai",
101 BaseURL: "https://api.deepseek.com",
102 APIKeyEnv: "DEEPSEEK_API_KEY",
103 }}}
104 normalizeLegacyProviderModels(c)
105 if got := c.Providers[0].Model; got != "deepseek-v4-flash" {
106 t.Fatalf("deepseek-flash model = %q, want deepseek-v4-flash", got)
107 }
108 }
109
110 func TestNormalizeLegacyProviderModelsLeavesCustomProviderUntouched(t *testing.T) {
111 c := &Config{Providers: []ProviderEntry{{
112 Name: "custom",
113 Kind: "openai",
114 BaseURL: "https://proxy.example.com/v1",
115 }}}
116 normalizeLegacyProviderModels(c)
117 if got := c.Providers[0].Model; got != "" {
118 t.Fatalf("custom provider model = %q, want empty", got)
119 }
120 }
121
122 func TestNormalizeLegacyStepFunBaseURLsPreservesRegionalProviders(t *testing.T) {
123 c := &Config{Providers: []ProviderEntry{
124 {
125 Name: "stepfun",
126 Kind: "openai",
127 BaseURL: legacyStepFunOpenAIBaseURL,
128 APIKeyEnv: "STEPFUN_API_KEY",
129 PresetID: "stepfun",
130 },
131 {
132 Name: "stepfun-anthropic",
133 Kind: "anthropic",
134 BaseURL: legacyStepFunAnthropicBaseURL + "/",
135 APIKeyEnv: "STEPFUN_API_KEY",
136 PresetID: "stepfun-anthropic",
137 },
138 {
139 Name: "custom-stepfun",
140 Kind: "openai",
141 BaseURL: legacyStepFunOpenAIBaseURL,
142 APIKeyEnv: "STEPFUN_API_KEY",
143 },
144 }}
145
146 if normalizeLegacyStepFunBaseURLs(c) {
147 t.Fatal("StepFun regional URL preservation unexpectedly reported a change")
148 }
149 if got := c.Providers[0].BaseURL; got != legacyStepFunOpenAIBaseURL {
150 t.Fatalf("global stepfun base_url = %q, want %q", got, legacyStepFunOpenAIBaseURL)
151 }
152 if got := c.Providers[1].BaseURL; got != legacyStepFunAnthropicBaseURL+"/" {
153 t.Fatalf("global stepfun-anthropic base_url = %q, want preserved trailing slash", got)
154 }
155 if got := c.Providers[2].BaseURL; got != legacyStepFunOpenAIBaseURL {
156 t.Fatalf("custom provider base_url = %q, want untouched legacy URL", got)
157 }
158 }
159
160 func TestLoadAndSavePreserveStepFunRegionalBaseURLs(t *testing.T) {
161 path := filepath.Join(t.TempDir(), "config.toml")
162 cfg := Default()
163 stepfun, ok := CuratedProviderPreset("stepfun")
164 if !ok || len(stepfun.Entries) != 1 {
165 t.Fatal("missing stepfun preset")
166 }
167 stepfunEntry := stepfun.Entries[0]
168 stepfunEntry.BaseURL = legacyStepFunOpenAIBaseURL
169 stepfunAnthropic, ok := CuratedProviderPreset("stepfun-anthropic")
170 if !ok || len(stepfunAnthropic.Entries) != 1 {
171 t.Fatal("missing stepfun-anthropic preset")
172 }
173 stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
174 stepfunAnthropicEntry.BaseURL = legacyStepFunAnthropicBaseURL
175 cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
176 cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
177 if err := cfg.SaveTo(path); err != nil {
178 t.Fatalf("SaveTo: %v", err)
179 }
180
181 loaded := LoadForEdit(path)
182 if got, _ := loaded.Provider("stepfun"); got == nil || got.BaseURL != legacyStepFunOpenAIBaseURL {
183 t.Fatalf("loaded stepfun = %+v, want global base URL preserved", got)
184 }
185 if got, _ := loaded.Provider("stepfun-anthropic"); got == nil || got.BaseURL != legacyStepFunAnthropicBaseURL {
186 t.Fatalf("loaded stepfun-anthropic = %+v, want global base URL preserved", got)
187 }
188
189 var disk Config
190 if _, err := toml.DecodeFile(path, &disk); err != nil {
191 t.Fatalf("decode config after read-only load: %v", err)
192 }
193 if got, _ := disk.Provider("stepfun"); got == nil || got.BaseURL != legacyStepFunOpenAIBaseURL {
194 t.Fatalf("read-only LoadForEdit rewrote stepfun = %+v, want legacy base URL preserved on disk", got)
195 }
196 if err := loaded.SaveTo(path); err != nil {
197 t.Fatalf("explicit SaveTo: %v", err)
198 }
199 disk = Config{}
200 if _, err := toml.DecodeFile(path, &disk); err != nil {
201 t.Fatalf("decode explicitly saved config: %v", err)
202 }
203 if got, _ := disk.Provider("stepfun"); got == nil || got.BaseURL != legacyStepFunOpenAIBaseURL {
204 t.Fatalf("persisted stepfun = %+v, want global base URL preserved", got)
205 }
206 if got, _ := disk.Provider("stepfun-anthropic"); got == nil || got.BaseURL != legacyStepFunAnthropicBaseURL {
207 t.Fatalf("persisted stepfun-anthropic = %+v, want global base URL preserved", got)
208 }
209 }
210
211 func TestNormalizeLegacyLongCatContextWindowsMigratesOnlyUntouchedOfficialPresets(t *testing.T) {
212 c := &Config{Providers: []ProviderEntry{
213 {
214 Name: "longcat-openai",
215 Kind: "openai",
216 BaseURL: longCatOpenAIBaseURL,
217 Models: []string{"LongCat-2.0"},
218 Default: "LongCat-2.0",
219 PresetID: "longcat-openai",
220 ContextWindow: legacyLongCat20ContextWindow,
221 },
222 {
223 Name: "longcat-anthropic",
224 Kind: "anthropic",
225 BaseURL: longCatAnthropicBaseURL + "/",
226 Models: []string{"LongCat-2.0"},
227 Default: "LongCat-2.0",
228 PresetID: "longcat-anthropic",
229 ContextWindow: legacyLongCat20ContextWindow,
230 },
231 {
232 Name: "custom-longcat",
233 Kind: "openai",
234 BaseURL: longCatOpenAIBaseURL,
235 Models: []string{"LongCat-2.0"},
236 Default: "LongCat-2.0",
237 ContextWindow: legacyLongCat20ContextWindow,
238 },
239 {
240 Name: "longcat-custom-window",
241 Kind: "openai",
242 BaseURL: longCatOpenAIBaseURL,
243 Models: []string{"LongCat-2.0"},
244 Default: "LongCat-2.0",
245 PresetID: "longcat-openai",
246 ContextWindow: 262_144,
247 },
248 {
249 Name: "longcat-custom-models",
250 Kind: "openai",
251 BaseURL: longCatOpenAIBaseURL,
252 Models: []string{"LongCat-2.0", "LongCat-Future"},
253 Default: "LongCat-2.0",
254 PresetID: "longcat-openai",
255 ContextWindow: legacyLongCat20ContextWindow,
256 },
257 {
258 Name: "longcat-custom-endpoint",
259 Kind: "openai",
260 BaseURL: "https://api.longcat.chat/custom/v1",
261 Models: []string{"LongCat-2.0"},
262 Default: "LongCat-2.0",
263 PresetID: "longcat-openai",
264 ContextWindow: legacyLongCat20ContextWindow,
265 },
266 {
267 Name: "longcat-custom-default",
268 Kind: "openai",
269 BaseURL: longCatOpenAIBaseURL,
270 Models: []string{"LongCat-2.0"},
271 PresetID: "longcat-openai",
272 ContextWindow: legacyLongCat20ContextWindow,
273 },
274 }}
275
276 if !normalizeLegacyLongCatContextWindows(c) {
277 t.Fatal("legacy LongCat context-window migration did not report a change")
278 }
279 if got := c.Providers[0].ContextWindow; got != longCat20ContextWindow {
280 t.Fatalf("longcat-openai context_window = %d, want %d", got, longCat20ContextWindow)
281 }
282 if got := c.Providers[1].ContextWindow; got != longCat20ContextWindow {
283 t.Fatalf("longcat-anthropic context_window = %d, want %d", got, longCat20ContextWindow)
284 }
285 if got := c.Providers[2].ContextWindow; got != legacyLongCat20ContextWindow {
286 t.Fatalf("custom LongCat context_window = %d, want unchanged %d", got, legacyLongCat20ContextWindow)
287 }
288 if got := c.Providers[3].ContextWindow; got != 262_144 {
289 t.Fatalf("customized preset context_window = %d, want unchanged 262144", got)
290 }
291 if got := c.Providers[4].ContextWindow; got != legacyLongCat20ContextWindow {
292 t.Fatalf("customized model catalog context_window = %d, want unchanged %d", got, legacyLongCat20ContextWindow)
293 }
294 if got := c.Providers[5].ContextWindow; got != legacyLongCat20ContextWindow {
295 t.Fatalf("customized endpoint context_window = %d, want unchanged %d", got, legacyLongCat20ContextWindow)
296 }
297 if got := c.Providers[6].ContextWindow; got != legacyLongCat20ContextWindow {
298 t.Fatalf("customized default model context_window = %d, want unchanged %d", got, legacyLongCat20ContextWindow)
299 }
300 }
301
302 func TestLoadForEditAppliesLegacyLongCatContextWindowMigration(t *testing.T) {
303 path := filepath.Join(t.TempDir(), "config.toml")
304 cfg := Default()
305 for _, id := range []string{"longcat-openai", "longcat-anthropic"} {
306 preset, ok := CuratedProviderPreset(id)
307 if !ok || len(preset.Entries) != 1 {
308 t.Fatalf("missing %s preset", id)
309 }
310 entry := preset.Entries[0]
311 entry.ContextWindow = legacyLongCat20ContextWindow
312 cfg.Providers = append(cfg.Providers, entry)
313 }
314 if err := cfg.SaveTo(path); err != nil {
315 t.Fatalf("SaveTo: %v", err)
316 }
317
318 loaded := LoadForEdit(path)
319 for _, id := range []string{"longcat-openai", "longcat-anthropic"} {
320 if got, _ := loaded.Provider(id); got == nil || got.ContextWindow != longCat20ContextWindow {
321 t.Fatalf("loaded %s = %+v, want context_window %d", id, got, longCat20ContextWindow)
322 }
323 }
324 }
325
326 func TestNormalizeLegacyQwenContextWindowsMigratesOnlyOfficialPresets(t *testing.T) {
327 qwenIDs := []string{
328 "qwen-cn",
329 "qwen-global",
330 "qwen-coding-plan-cn",
331 "qwen-coding-plan-cn-anthropic",
332 "qwen-coding-plan-global",
333 "qwen-coding-plan-global-anthropic",
334 }
335 c := &Config{}
336 for _, id := range qwenIDs {
337 preset, ok := CuratedProviderPreset(id)
338 if !ok || len(preset.Entries) != 1 {
339 t.Fatalf("missing %s preset", id)
340 }
341 entry := preset.Entries[0]
342 entry.ContextWindow = 0
343 entry.ModelOverrides = nil
344 c.Providers = append(c.Providers, entry)
345 }
346
347 preset, _ := CuratedProviderPreset("qwen-cn")
348 customWindow := preset.Entries[0]
349 customWindow.Name = "qwen-custom-window"
350 customWindow.ContextWindow = 131_072
351 customWindow.ModelOverrides = nil
352 customEndpoint := preset.Entries[0]
353 customEndpoint.Name = "qwen-custom-endpoint"
354 customEndpoint.BaseURL = "https://gateway.example.com/v1"
355 customEndpoint.ContextWindow = 0
356 customEndpoint.ModelOverrides = nil
357 customCatalog := preset.Entries[0]
358 customCatalog.Name = "qwen-custom-catalog"
359 customCatalog.Models = append(customCatalog.Models, "private-model")
360 customCatalog.ContextWindow = 0
361 customCatalog.ModelOverrides = nil
362 customOverride := preset.Entries[0]
363 customOverride.Name = "qwen-custom-override"
364 customOverride.ContextWindow = 0
365 customOverride.VisionModels = []string{}
366 customOverride.ModelOverrides = map[string]ProviderModelOverride{
367 "GLM-5": {
368 ReasoningProtocol: ReasoningProtocolNone,
369 ContextWindow: 123_456,
370 },
371 }
372 c.Providers = append(c.Providers, customWindow, customEndpoint, customCatalog, customOverride)
373
374 if !normalizeLegacyQwenContextWindows(c) {
375 t.Fatal("legacy Qwen context-window migration did not report a change")
376 }
377 wantOverrides := qwenModelContextOverrides()
378 for i, id := range qwenIDs {
379 got := &c.Providers[i]
380 if got.ContextWindow != 1_000_000 {
381 t.Fatalf("%s context_window = %d, want 1000000", id, got.ContextWindow)
382 }
383 for model, want := range wantOverrides {
384 if got.ModelOverrides[model].ContextWindow != want.ContextWindow {
385 t.Fatalf("%s/%s context_window = %d, want %d", id, model, got.ModelOverrides[model].ContextWindow, want.ContextWindow)
386 }
387 }
388 }
389
390 if got := c.Providers[len(qwenIDs)]; got.ContextWindow != 131_072 || got.ModelOverrides != nil {
391 t.Fatalf("custom provider-wide context changed: %+v", got)
392 }
393 if got := c.Providers[len(qwenIDs)+1]; got.ContextWindow != 0 || got.ModelOverrides != nil {
394 t.Fatalf("custom endpoint changed: %+v", got)
395 }
396 if got := c.Providers[len(qwenIDs)+2]; got.ContextWindow != 0 || got.ModelOverrides != nil {
397 t.Fatalf("custom catalog changed: %+v", got)
398 }
399 gotCustom := c.Providers[len(qwenIDs)+3]
400 if gotCustom.ContextWindow != 1_000_000 || gotCustom.ModelOverrides["GLM-5"].ContextWindow != 123_456 {
401 t.Fatalf("custom model override changed: %+v", gotCustom)
402 }
403 if _, duplicate := gotCustom.ModelOverrides["glm-5"]; duplicate {
404 t.Fatal("migration added a duplicate case-insensitive GLM-5 override")
405 }
406 if got := gotCustom.ModelOverrides["MiniMax-M2.5"].ContextWindow; got != 196_608 {
407 t.Fatalf("missing MiniMax override was not backfilled: %d", got)
408 }
409 if gotCustom.VisionModels == nil || len(gotCustom.VisionModels) != 0 {
410 t.Fatalf("custom vision models changed: %#v", gotCustom.VisionModels)
411 }
412 }
413
414 func TestLoadForEditAppliesLegacyQwenContextWindowMigration(t *testing.T) {
415 path := filepath.Join(t.TempDir(), "config.toml")
416 cfg := Default()
417 preset, ok := CuratedProviderPreset("qwen-cn")
418 if !ok || len(preset.Entries) != 1 {
419 t.Fatal("missing qwen-cn preset")
420 }
421 entry := preset.Entries[0]
422 entry.ContextWindow = 0
423 entry.ModelOverrides = nil
424 cfg.Providers = append(cfg.Providers, entry)
425 if err := cfg.SaveTo(path); err != nil {
426 t.Fatalf("SaveTo: %v", err)
427 }
428
429 loaded := LoadForEditWithoutCredentials(path)
430 got, ok := loaded.Provider("qwen-cn")
431 if !ok || got.ContextWindow != 1_000_000 {
432 t.Fatalf("loaded qwen-cn = %+v, want context_window 1000000", got)
433 }
434 if resolved, ok := loaded.ResolveModel("qwen-cn/glm-5"); !ok || resolved.ContextWindow != 202_752 {
435 t.Fatalf("resolved qwen-cn/glm-5 = %+v/%v, want context_window 202752", resolved, ok)
436 }
437
438 var disk Config
439 if _, err := toml.DecodeFile(path, &disk); err != nil {
440 t.Fatalf("decode config after read-only load: %v", err)
441 }
442 if persisted, _ := disk.Provider("qwen-cn"); persisted == nil || persisted.ContextWindow != 0 {
443 t.Fatalf("read-only load rewrote qwen-cn = %+v, want legacy config preserved on disk", persisted)
444 }
445 if err := EditConfigFileWithoutCredentials(path, func(*Config) error { return nil }); err != nil {
446 t.Fatalf("EditConfigFileWithoutCredentials: %v", err)
447 }
448 disk = Config{}
449 if _, err := toml.DecodeFile(path, &disk); err != nil {
450 t.Fatalf("decode config after edit: %v", err)
451 }
452 persisted, ok := disk.Provider("qwen-cn")
453 if !ok || persisted.ContextWindow != 1_000_000 || persisted.ModelOverrides["glm-5"].ContextWindow != 202_752 {
454 t.Fatalf("persisted qwen-cn = %+v, want migrated context defaults", persisted)
455 }
456 }
457
458 func TestNormalizeLegacyOpenCodeGoKimiK3CatalogMigratesOnlyUntouchedPreset(t *testing.T) {
459 legacyEntry := ProviderEntry{
460 Name: "opencode-go",
461 Kind: "openai",
462 BaseURL: "https://opencode.ai/zen/go/v1/",
463 Models: append([]string(nil), legacyOpenCodeGoModels...),
464 Default: "glm-5.2",
465 APIKeyEnv: "CUSTOM_OPENCODE_GO_KEY",
466 ContextWindow: 256_000,
467 PresetID: "opencode-go",
468 ModelOverrides: map[string]ProviderModelOverride{
469 "deepseek-v4-pro": {
470 ReasoningProtocol: ReasoningProtocolDeepSeek,
471 SupportedEfforts: []string{"high", "max"},
472 DefaultEffort: "high",
473 },
474 },
475 }
476 customModels := legacyEntry
477 customModels.Name = "opencode-go-custom-models"
478 customModels.Models = append(append([]string(nil), customModels.Models...), "private-model")
479 customModels.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
480 customEndpoint := legacyEntry
481 customEndpoint.Name = "opencode-go-custom-endpoint"
482 customEndpoint.BaseURL = "https://gateway.example.com/v1"
483 customEndpoint.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
484 noPresetIdentity := legacyEntry
485 noPresetIdentity.Name = "opencode-go-copy"
486 noPresetIdentity.PresetID = ""
487 noPresetIdentity.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
488 c := &Config{Providers: []ProviderEntry{legacyEntry, customModels, customEndpoint, noPresetIdentity}}
489
490 if !normalizeLegacyOpenCodeGoKimiK3Catalog(c) {
491 t.Fatal("legacy OpenCode Go catalog migration did not report a change")
492 }
493 got := &c.Providers[0]
494 if !got.HasModel("kimi-k3") || !got.HasVisionModel("kimi-k3") {
495 t.Fatalf("migrated OpenCode Go catalog = %+v, want Kimi K3 with vision", got)
496 }
497 k3 := got.ModelOverrides["kimi-k3"]
498 if k3.ReasoningProtocol != ReasoningProtocolOpenAI ||
499 !stringSlicesEqual(k3.SupportedEfforts, []string{"high", "max"}) ||
500 k3.DefaultEffort != "max" ||
501 k3.ContextWindow != 1_048_576 {
502 t.Fatalf("migrated Kimi K3 override = %+v", k3)
503 }
504 if got.APIKeyEnv != "CUSTOM_OPENCODE_GO_KEY" || got.ContextWindow != 256_000 {
505 t.Fatalf("unrelated provider edits were not preserved: %+v", got)
506 }
507 if _, ok := got.ModelOverrides["deepseek-v4-pro"]; !ok {
508 t.Fatal("existing model override was dropped")
509 }
510 for i := 1; i < len(c.Providers); i++ {
511 if c.Providers[i].HasModel("kimi-k3") {
512 t.Fatalf("customized provider %q was unexpectedly migrated", c.Providers[i].Name)
513 }
514 }
515
516 preIdentity := legacyEntry
517 preIdentity.PresetID = ""
518 preIdentity.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
519 preIdentityConfig := &Config{Providers: []ProviderEntry{preIdentity}}
520 if !normalizeLegacyOpenCodeGoKimiK3Catalog(preIdentityConfig) || !preIdentityConfig.Providers[0].HasModel("kimi-k3") {
521 t.Fatal("pre-preset-identity OpenCode Go install was not migrated")
522 }
523
524 vision := false
525 customK3 := legacyEntry
526 customK3.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
527 delete(customK3.ModelOverrides, "kimi-k3")
528 wantK3 := ProviderModelOverride{
529 ReasoningProtocol: ReasoningProtocolNone,
530 SupportedEfforts: []string{"low"},
531 DefaultEffort: "low",
532 Vision: &vision,
533 ContextWindow: 262_144,
534 }
535 customK3.ModelOverrides["KIMI-K3"] = wantK3
536 customK3Config := &Config{Providers: []ProviderEntry{customK3}}
537 if !normalizeLegacyOpenCodeGoKimiK3Catalog(customK3Config) {
538 t.Fatal("legacy OpenCode Go catalog with custom Kimi K3 override was not migrated")
539 }
540 gotK3, ok := customK3Config.Providers[0].ModelOverrides["KIMI-K3"]
541 if !ok || !reflect.DeepEqual(gotK3, wantK3) {
542 t.Fatalf("custom Kimi K3 override = %+v, want preserved %+v", gotK3, wantK3)
543 }
544 if _, duplicate := customK3Config.Providers[0].ModelOverrides["kimi-k3"]; duplicate {
545 t.Fatal("migration added a duplicate case-insensitive Kimi K3 override")
546 }
547 }
548
549 func TestNormalizeLegacyKimiK3CatalogMigratesOnlyOfficialUntouchedPresets(t *testing.T) {
550 legacyEntry := func(id, baseURL string) ProviderEntry {
551 return ProviderEntry{
552 Name: id,
553 Kind: "openai",
554 BaseURL: baseURL + "/",
555 Models: append([]string(nil), legacyKimiAPIModels...),
556 VisionModels: append([]string(nil), legacyKimiAPIModels...),
557 Default: "kimi-k2.7-code",
558 APIKeyEnv: "CUSTOM_KIMI_KEY",
559 ContextWindow: 300_000,
560 ReasoningProtocol: ReasoningProtocolNone,
561 PresetID: id,
562 }
563 }
564 cn := legacyEntry("kimi-cn", "https://api.moonshot.cn/v1")
565 cn.Name = "my-kimi-cn"
566 global := legacyEntry("kimi-global", "https://api.moonshot.ai/v1")
567 global.PresetID = ""
568 customModels := legacyEntry("kimi-cn", "https://api.moonshot.cn/v1")
569 customModels.Models = append(customModels.Models, "private-model")
570 customEndpoint := legacyEntry("kimi-global", "https://api.moonshot.ai/v1")
571 customEndpoint.BaseURL = "https://gateway.example.com/v1"
572 selectedModel := legacyEntry("kimi-cn", "https://api.moonshot.cn/v1")
573 selectedModel.Model = "kimi-k2.7-code"
574 c := &Config{Providers: []ProviderEntry{cn, global, customModels, customEndpoint, selectedModel}}
575
576 if !normalizeLegacyKimiK3Catalog(c) {
577 t.Fatal("legacy Kimi direct catalogs did not report a change")
578 }
579 for i := range 2 {
580 got := &c.Providers[i]
581 if !got.HasModel("kimi-k3") || !got.HasVisionModel("kimi-k3") {
582 t.Fatalf("migrated Kimi provider %d = %+v, want K3 with vision", i, got)
583 }
584 k3 := got.ModelOverrides["kimi-k3"]
585 if k3.ReasoningProtocol != ReasoningProtocolOpenAI ||
586 !stringSlicesEqual(k3.SupportedEfforts, []string{"low", "high", "max"}) ||
587 k3.DefaultEffort != "max" || k3.ContextWindow != 1_048_576 {
588 t.Fatalf("migrated Kimi K3 override %d = %+v", i, k3)
589 }
590 if got.APIKeyEnv != "CUSTOM_KIMI_KEY" || got.ContextWindow != 300_000 || got.Default != "kimi-k2.7-code" {
591 t.Fatalf("unrelated Kimi provider edits were not preserved: %+v", got)
592 }
593 }
594 for i := 2; i < len(c.Providers); i++ {
595 if c.Providers[i].HasModel("kimi-k3") {
596 t.Fatalf("customized Kimi provider %q was unexpectedly migrated", c.Providers[i].Name)
597 }
598 }
599
600 vision := false
601 customK3 := legacyEntry("kimi-cn", "https://api.moonshot.cn/v1")
602 wantK3 := ProviderModelOverride{
603 ReasoningProtocol: ReasoningProtocolNone,
604 SupportedEfforts: []string{"low"},
605 DefaultEffort: "low",
606 Vision: &vision,
607 ContextWindow: 262_144,
608 }
609 customK3.ModelOverrides = map[string]ProviderModelOverride{"KIMI-K3": wantK3}
610 customConfig := &Config{Providers: []ProviderEntry{customK3}}
611 if !normalizeLegacyKimiK3Catalog(customConfig) {
612 t.Fatal("legacy Kimi catalog with custom K3 override was not migrated")
613 }
614 gotK3, ok := customConfig.Providers[0].ModelOverrides["KIMI-K3"]
615 if !ok || !reflect.DeepEqual(gotK3, wantK3) {
616 t.Fatalf("custom direct Kimi K3 override = %+v, want preserved %+v", gotK3, wantK3)
617 }
618 if _, duplicate := customConfig.Providers[0].ModelOverrides["kimi-k3"]; duplicate {
619 t.Fatal("migration added a duplicate case-insensitive direct Kimi K3 override")
620 }
621 }
622
623 func TestNormalizeLegacyKimiK3CatalogPreservesVisionChoices(t *testing.T) {
624 base := ProviderEntry{
625 Name: "kimi-cn",
626 Kind: "openai",
627 BaseURL: "https://api.moonshot.cn/v1",
628 Models: append([]string(nil), legacyKimiAPIModels...),
629 Default: "kimi-k2.7-code",
630 PresetID: "kimi-cn",
631 VisionModels: []string{},
632 }
633
634 custom := base
635 custom.VisionModels = []string{"kimi-k2.5"}
636 c := &Config{Providers: []ProviderEntry{base, custom}}
637
638 if !normalizeLegacyKimiK3Catalog(c) {
639 t.Fatal("legacy Kimi catalog migration did not report a change")
640 }
641 if got := c.Providers[0].VisionModels; got == nil || len(got) != 0 {
642 t.Fatalf("explicitly disabled vision models = %#v, want explicit empty list", got)
643 }
644 if got := c.Providers[1].VisionModels; !reflect.DeepEqual(got, []string{"kimi-k2.5"}) {
645 t.Fatalf("custom vision models = %v, want [kimi-k2.5]", got)
646 }
647 }
648
649 func TestLoadForEditKeepsLegacyKimiK3CatalogMigrationInMemoryUntilSave(t *testing.T) {
650 path := filepath.Join(t.TempDir(), "config.toml")
651 cfg := Default()
652 preset, ok := CuratedProviderPreset("kimi-cn")
653 if !ok || len(preset.Entries) != 1 {
654 t.Fatal("missing kimi-cn preset")
655 }
656 entry := preset.Entries[0]
657 entry.Models = append([]string(nil), legacyKimiAPIModels...)
658 entry.VisionModels = append([]string(nil), legacyKimiAPIModels...)
659 delete(entry.ModelOverrides, "kimi-k3")
660 cfg.Providers = append(cfg.Providers, entry)
661 if err := cfg.SaveTo(path); err != nil {
662 t.Fatalf("SaveTo: %v", err)
663 }
664
665 loaded := LoadForEdit(path)
666 got, ok := loaded.Provider("kimi-cn")
667 if !ok || !got.HasModel("kimi-k3") || !got.HasVisionModel("kimi-k3") {
668 t.Fatalf("loaded kimi-cn = %+v, want persisted K3 catalog", got)
669 }
670 var disk Config
671 if _, err := toml.DecodeFile(path, &disk); err != nil {
672 t.Fatalf("decode config after read-only load: %v", err)
673 }
674 persisted, ok := disk.Provider("kimi-cn")
675 if !ok || persisted.HasModel("kimi-k3") {
676 t.Fatalf("read-only LoadForEdit rewrote kimi-cn = %+v, want legacy catalog preserved on disk", persisted)
677 }
678 if err := loaded.SaveTo(path); err != nil {
679 t.Fatalf("explicit SaveTo: %v", err)
680 }
681 disk = Config{}
682 if _, err := toml.DecodeFile(path, &disk); err != nil {
683 t.Fatalf("decode explicitly saved config: %v", err)
684 }
685 persisted, ok = disk.Provider("kimi-cn")
686 if !ok || !persisted.HasModel("kimi-k3") || persisted.ModelOverrides["kimi-k3"].DefaultEffort != "max" {
687 t.Fatalf("persisted kimi-cn = %+v, want K3 defaults", persisted)
688 }
689 }
690
691 func TestLoadForEditKeepsLegacyOpenCodeGoKimiK3CatalogMigrationInMemoryUntilSave(t *testing.T) {
692 path := filepath.Join(t.TempDir(), "config.toml")
693 cfg := Default()
694 preset, ok := CuratedProviderPreset("opencode-go")
695 if !ok || len(preset.Entries) != 1 {
696 t.Fatal("missing opencode-go preset")
697 }
698 entry := preset.Entries[0]
699 entry.Models = append([]string(nil), legacyOpenCodeGoModels...)
700 entry.VisionModels = nil
701 delete(entry.ModelOverrides, "kimi-k3")
702 cfg.Providers = append(cfg.Providers, entry)
703 cfg.Desktop.ProviderAccess = []string{"opencode-go"}
704 if err := cfg.SaveTo(path); err != nil {
705 t.Fatalf("SaveTo: %v", err)
706 }
707
708 loaded := LoadForEdit(path)
709 got, ok := loaded.Provider("opencode-go")
710 if !ok || !got.HasModel("kimi-k3") || !got.HasVisionModel("kimi-k3") {
711 t.Fatalf("loaded opencode-go = %+v, want persisted Kimi K3 catalog", got)
712 }
713 if ov := got.ModelOverrides["kimi-k3"]; ov.DefaultEffort != "max" || ov.ContextWindow != 1_048_576 {
714 t.Fatalf("loaded Kimi K3 override = %+v", ov)
715 }
716
717 var disk Config
718 if _, err := toml.DecodeFile(path, &disk); err != nil {
719 t.Fatalf("decode config after read-only load: %v", err)
720 }
721 persisted, ok := disk.Provider("opencode-go")
722 if !ok || persisted.HasModel("kimi-k3") {
723 t.Fatalf("read-only LoadForEdit rewrote opencode-go = %+v, want legacy catalog preserved on disk", persisted)
724 }
725 if err := loaded.SaveTo(path); err != nil {
726 t.Fatalf("explicit SaveTo: %v", err)
727 }
728 disk = Config{}
729 if _, err := toml.DecodeFile(path, &disk); err != nil {
730 t.Fatalf("decode explicitly saved config: %v", err)
731 }
732 persisted, ok = disk.Provider("opencode-go")
733 if !ok || !persisted.HasModel("kimi-k3") || !persisted.HasVisionModel("kimi-k3") {
734 t.Fatalf("persisted opencode-go = %+v, want Kimi K3 catalog", persisted)
735 }
736 }
737
738 func TestNormalizeLegacyOpenCodeGoKimiK3CatalogPreservesVisionChoices(t *testing.T) {
739 base := ProviderEntry{
740 Name: "opencode-go",
741 Kind: "openai",
742 BaseURL: "https://opencode.ai/zen/go/v1",
743 Models: append([]string(nil), legacyOpenCodeGoModels...),
744 Default: "glm-5.2",
745 PresetID: "opencode-go",
746 }
747
748 tests := []struct {
749 name string
750 vision []string
751 want []string
752 }{
753 {name: "explicitly disabled", vision: []string{}, want: []string{}},
754 {name: "custom list", vision: []string{"mimo-v2.5"}, want: []string{"mimo-v2.5"}},
755 }
756 for _, tt := range tests {
757 t.Run(tt.name, func(t *testing.T) {
758 entry := base
759 entry.VisionModels = tt.vision
760 c := &Config{Providers: []ProviderEntry{entry}}
761 if !normalizeLegacyOpenCodeGoKimiK3Catalog(c) {
762 t.Fatal("legacy OpenCode Go catalog migration did not report a change")
763 }
764 if got := c.Providers[0].VisionModels; !reflect.DeepEqual(got, tt.want) {
765 t.Fatalf("vision models = %#v, want %#v", got, tt.want)
766 }
767 })
768 }
769 }
770
771 func TestNormalizeDesktopOfficialProviderAccessCanonicalizesOnlyDeepSeekIDs(t *testing.T) {
772 c := Default()
773 c.DefaultModel = "deepseek-flash/deepseek-v4-pro"
774 c.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
775 normalizeDesktopOfficialProviderAccess(c)
776 if len(c.Desktop.ProviderAccess) != 2 || c.Desktop.ProviderAccess[0] != "deepseek" || c.Desktop.ProviderAccess[1] != "mimo-pro" {
777 t.Fatalf("provider_access = %+v, want only DeepSeek canonicalized", c.Desktop.ProviderAccess)
778 }
779 if c.DefaultModel != "deepseek/deepseek-v4-pro" {
780 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", c.DefaultModel)
781 }
782 if _, ok := c.Provider("deepseek"); !ok {
783 t.Fatal("canonical deepseek provider missing")
784 }
785 if resolved, ok := c.ResolveModel("deepseek-pro/deepseek-v4-pro"); !ok || resolved.Name != "deepseek" {
786 t.Fatalf("compatible legacy reference resolved to %+v, found=%v; want canonical DeepSeek", resolved, ok)
787 }
788 if _, ok := c.Provider("mimo-token-plan"); ok {
789 t.Fatal("mimo-token-plan should not be injected as an official provider")
790 }
791 }
792
793 func TestNormalizeDesktopOfficialProviderAccessBackfillsOfficialContextWindow(t *testing.T) {
794 c := &Config{
795 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash", "mimo-api", "mimo-token-plan"}},
796 Providers: []ProviderEntry{
797 {
798 Name: "deepseek-flash",
799 Kind: "openai",
800 BaseURL: "https://api.deepseek.com",
801 Model: "deepseek-v4-flash",
802 APIKeyEnv: "DEEPSEEK_API_KEY",
803 },
804 {
805 Name: "mimo-api",
806 Kind: "openai",
807 BaseURL: "https://api.xiaomimimo.com/v1",
808 Model: "mimo-v2.5-pro",
809 APIKeyEnv: "MIMO_API_KEY",
810 },
811 {
812 Name: "mimo-token-plan",
813 Kind: "openai",
814 BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
815 Model: "mimo-v2.5-pro",
816 APIKeyEnv: "MIMO_API_KEY",
817 },
818 },
819 }
820
821 normalizeDesktopOfficialProviderAccess(c)
822
823 deepseek, ok := c.Provider("deepseek")
824 if !ok {
825 t.Fatal("deepseek provider missing")
826 }
827 if deepseek.ContextWindow != 1_000_000 {
828 t.Fatalf("deepseek context_window = %d, want official default", deepseek.ContextWindow)
829 }
830 mimoAPI, ok := c.Provider("mimo-api")
831 if !ok {
832 t.Fatal("mimo-api provider missing")
833 }
834 if mimoAPI.ContextWindow != 1_048_576 {
835 t.Fatalf("mimo-api context_window = %d, want migrated MiMo default", mimoAPI.ContextWindow)
836 }
837 mimoTokenPlan, ok := c.Provider("mimo-token-plan")
838 if !ok {
839 t.Fatal("mimo-token-plan provider missing")
840 }
841 if mimoTokenPlan.ContextWindow != 1_048_576 {
842 t.Fatalf("mimo-token-plan context_window = %d, want migrated MiMo default", mimoTokenPlan.ContextWindow)
843 }
844 }
845
846 func TestNormalizeDesktopOfficialProviderAccessKeepsCustomAlias(t *testing.T) {
847 c := &Config{
848 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash"}},
849 Providers: []ProviderEntry{{
850 Name: "deepseek-flash",
851 Kind: "openai",
852 BaseURL: "https://proxy.example/v1",
853 Model: "deepseek-v4-flash",
854 }},
855 }
856
857 normalizeDesktopOfficialProviderAccess(c)
858
859 if len(c.Desktop.ProviderAccess) != 1 || c.Desktop.ProviderAccess[0] != "deepseek-flash" {
860 t.Fatalf("provider_access = %+v, want custom alias preserved", c.Desktop.ProviderAccess)
861 }
862 if _, ok := c.Provider("deepseek"); ok {
863 t.Fatal("custom deepseek-flash proxy should not create canonical deepseek provider")
864 }
865 }
866
867 func TestNormalizeDesktopOfficialProviderAccessKeepsIncompatibleOfficialAliases(t *testing.T) {
868 c := &Config{
869 DefaultModel: "deepseek-pro/deepseek-v4-pro",
870 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
871 Providers: []ProviderEntry{
872 {
873 Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
874 Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
875 Headers: map[string]string{"X-Route": "flash"},
876 },
877 {
878 Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
879 Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
880 Headers: map[string]string{"X-Route": "pro"},
881 },
882 },
883 }
884
885 normalizeDesktopOfficialProviderAccess(c)
886
887 if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek-flash", "deepseek-pro"}) {
888 t.Fatalf("provider_access = %v, want incompatible aliases preserved separately", got)
889 }
890 if _, ok := c.Provider("deepseek"); ok {
891 t.Fatal("incompatible provider-wide headers must not be collapsed into a canonical provider")
892 }
893 if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
894 t.Fatalf("default_model = %q, want legacy Pro reference preserved", c.DefaultModel)
895 }
896 resolved, ok := c.ResolveModel(c.DefaultModel)
897 if !ok || resolved.Headers["X-Route"] != "pro" {
898 t.Fatalf("resolved Pro provider = %+v, found=%v; want its original transport", resolved, ok)
899 }
900 }
901
902 func TestNormalizeDesktopOfficialProviderAccessKeepsAliasIncompatibleWithExistingCanonical(t *testing.T) {
903 c := &Config{
904 DefaultModel: "deepseek-pro/deepseek-v4-pro",
905 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek", "deepseek-pro"}},
906 Providers: []ProviderEntry{
907 {
908 Name: "deepseek", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
909 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash",
910 APIKeyEnv: "DEEPSEEK_API_KEY",
911 },
912 {
913 Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com",
914 Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
915 Headers: map[string]string{"X-Route": "legacy-pro"},
916 },
917 },
918 }
919
920 normalizeDesktopOfficialProviderAccess(c)
921
922 if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek", "deepseek-pro"}) {
923 t.Fatalf("provider_access = %v, want canonical and incompatible alias preserved", got)
924 }
925 if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
926 t.Fatalf("default_model = %q, want incompatible legacy reference preserved", c.DefaultModel)
927 }
928 resolved, ok := c.ResolveModel(c.DefaultModel)
929 if !ok || resolved.Kind != "openai" || resolved.Headers["X-Route"] != "legacy-pro" {
930 t.Fatalf("resolved legacy provider = %+v, found=%v; want original transport", resolved, ok)
931 }
932 }
933
934 func TestNormalizeDesktopOfficialProviderAccessKeepsConflictingModelAliases(t *testing.T) {
935 c := &Config{
936 DefaultModel: "deepseek-pro/deepseek-v4-flash",
937 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
938 Providers: []ProviderEntry{
939 {
940 Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
941 Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 900_000,
942 },
943 {
944 Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
945 Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 800_000,
946 },
947 },
948 }
949
950 normalizeDesktopOfficialProviderAccess(c)
951
952 if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek-flash", "deepseek-pro"}) {
953 t.Fatalf("provider_access = %v, want aliases with conflicting model metadata preserved", got)
954 }
955 if _, ok := c.Provider("deepseek"); ok {
956 t.Fatal("conflicting model metadata must not be collapsed into a canonical provider")
957 }
958 resolved, ok := c.ResolveModel(c.DefaultModel)
959 if !ok || resolved.ContextWindow != 800_000 {
960 t.Fatalf("resolved Pro alias = %+v, found=%v; want its original context window", resolved, ok)
961 }
962 }
963
964 func TestNormalizeDesktopOfficialProviderAccessKeepsUnsupportedOfficialProtocolAlias(t *testing.T) {
965 c := &Config{
966 DefaultModel: "deepseek-flash/deepseek-v4-flash",
967 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash"}},
968 Providers: []ProviderEntry{{
969 Name: "deepseek-flash", Kind: "responses", BaseURL: "https://api.deepseek.com",
970 Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
971 }},
972 }
973
974 normalizeDesktopOfficialProviderAccess(c)
975
976 if got := c.Desktop.ProviderAccess; !reflect.DeepEqual(got, []string{"deepseek-flash"}) {
977 t.Fatalf("provider_access = %v, want unsupported legacy protocol alias preserved", got)
978 }
979 if _, ok := c.Provider("deepseek"); ok {
980 t.Fatal("a legacy Responses alias must not be rewritten into the Anthropic canonical provider")
981 }
982 if c.DefaultModel != "deepseek-flash/deepseek-v4-flash" {
983 t.Fatalf("default_model = %q, want unsupported protocol reference preserved", c.DefaultModel)
984 }
985 }
986
987 func TestNormalizeDesktopOfficialProviderAccessPreservesDefaultAndUnknownPrices(t *testing.T) {
988 futurePrice := &provider.Pricing{Input: 4.2, Output: 8.4, Currency: "T"}
989 c := &Config{
990 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
991 Providers: []ProviderEntry{
992 {
993 Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
994 Models: []string{"deepseek-v4-flash", "deepseek-v5-preview"}, Default: "deepseek-v5-preview",
995 APIKeyEnv: "DEEPSEEK_API_KEY", Prices: map[string]*provider.Pricing{"deepseek-v5-preview": futurePrice},
996 },
997 {
998 Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
999 Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
1000 },
1001 },
1002 }
1003
1004 normalizeDesktopOfficialProviderAccess(c)
1005
1006 canonical, ok := c.Provider("deepseek")
1007 if !ok {
1008 t.Fatal("canonical DeepSeek provider missing")
1009 }
1010 if canonical.Default != "deepseek-v5-preview" {
1011 t.Fatalf("canonical default = %q, want explicit legacy default preserved", canonical.Default)
1012 }
1013 got := canonical.Prices["deepseek-v5-preview"]
1014 if got == nil || !reflect.DeepEqual(got, futurePrice) {
1015 t.Fatalf("future model price = %+v, want %+v", got, futurePrice)
1016 }
1017 }
1018
1019 func TestNormalizeOfficialDeepSeekModelsRepairsCanonicalProvider(t *testing.T) {
1020 c := &Config{
1021 DefaultModel: "deepseek-flash/deepseek-v4-flash",
1022 Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
1023 Providers: []ProviderEntry{{
1024 Name: "deepseek",
1025 Kind: "openai",
1026 BaseURL: "https://api.deepseek.com",
1027 Model: "glm-5",
1028 APIKeyEnv: "DEEPSEEK_API_KEY",
1029 }},
1030 }
1031 normalizeDesktopOfficialProviderAccess(c)
1032 normalizeOfficialDeepSeekModels(c)
1033
1034 p, ok := c.Provider("deepseek")
1035 if !ok {
1036 t.Fatal("deepseek provider missing")
1037 }
1038 if !p.HasModel("deepseek-v4-flash") || !p.HasModel("deepseek-v4-pro") || !p.HasModel("glm-5") {
1039 t.Fatalf("deepseek models = %+v, want official models plus existing model", p.ModelList())
1040 }
1041 if c.DefaultModel != "deepseek/deepseek-v4-flash" {
1042 t.Fatalf("default_model = %q, want retargeted official ref", c.DefaultModel)
1043 }
1044 if _, ok := c.ResolveModel(c.DefaultModel); !ok {
1045 t.Fatalf("retargeted default_model %q should resolve", c.DefaultModel)
1046 }
1047 }
1048
1049 func TestNormalizeOfficialDeepSeekModelsLeavesExternalEndpointUntouched(t *testing.T) {
1050 c := &Config{Providers: []ProviderEntry{{
1051 Name: "deepseek",
1052 Kind: "openai",
1053 BaseURL: "https://proxy.example.com/v1",
1054 Model: "glm-5",
1055 }}}
1056 normalizeOfficialDeepSeekModels(c)
1057
1058 p, ok := c.Provider("deepseek")
1059 if !ok {
1060 t.Fatal("deepseek provider missing")
1061 }
1062 if p.HasModel("deepseek-v4-flash") || p.HasModel("deepseek-v4-pro") {
1063 t.Fatalf("external endpoint models = %+v, want untouched custom list", p.ModelList())
1064 }
1065 }
1066
1067 func TestNormalizeLegacyMimoCustomProvidersEnsuresReferencedMimoAPI(t *testing.T) {
1068 c := Default()
1069 c.DefaultModel = "mimo-api/mimo-v2.5-pro"
1070 c.Desktop.ProviderAccess = []string{"mimo-api"}
1071 if !normalizeLegacyMimoCustomProviders(c) {
1072 t.Fatal("legacy MiMo migration did not report a change")
1073 }
1074 p, ok := c.Provider("mimo-api")
1075 if !ok {
1076 t.Fatal("mimo-api paid provider missing")
1077 }
1078 if !p.HasModel("mimo-v2.5") || !p.HasModel("mimo-v2-omni") {
1079 t.Fatalf("mimo-api models = %v, want vision-capable MiMo models", p.ModelList())
1080 }
1081 normalizeDesktopOfficialProviderAccess(c)
1082 if got := c.Desktop.ProviderAccess; len(got) != 1 || got[0] != "mimo-api" {
1083 t.Fatalf("provider_access = %+v, want mimo-api", got)
1084 }
1085 }
1086
1087 func TestNormalizeLegacyMimoCustomProvidersRecognizesBareModelRefs(t *testing.T) {
1088 c := Default()
1089 c.DefaultModel = "mimo-v2.5-pro"
1090 if !normalizeLegacyMimoCustomProviders(c) {
1091 t.Fatal("legacy bare MiMo model migration did not report a change")
1092 }
1093 p, ok := c.Provider("mimo-pro")
1094 if !ok {
1095 t.Fatal("mimo-pro provider missing")
1096 }
1097 if p.Model != "mimo-v2.5-pro" {
1098 t.Fatalf("mimo-pro model = %q, want mimo-v2.5-pro", p.Model)
1099 }
1100 if e, ok := c.ResolveModel("mimo-v2.5-pro"); !ok || e.Name != "mimo-pro" {
1101 t.Fatalf("bare MiMo model resolved to %+v/%v, want mimo-pro", e, ok)
1102 }
1103 }
1104
1105 func TestNormalizeLegacyMimoCustomProvidersScansBotRefs(t *testing.T) {
1106 c := Default()
1107 c.Bot.Model = "mimo-pro"
1108 c.Bot.Connections = []BotConnectionConfig{{Model: "mimo-flash"}}
1109 if !normalizeLegacyMimoCustomProviders(c) {
1110 t.Fatal("legacy bot MiMo migration did not report a change")
1111 }
1112 if _, ok := c.Provider("mimo-pro"); !ok {
1113 t.Fatal("mimo-pro provider missing")
1114 }
1115 if _, ok := c.Provider("mimo-flash"); !ok {
1116 t.Fatal("mimo-flash provider missing")
1117 }
1118 }
1119
1120 func TestNormalizeLegacyDesktopProviderAccessIncludesUnconfiguredMimoRefs(t *testing.T) {
1121 c := Default()
1122 c.DefaultModel = "mimo-pro"
1123 c.Bot.Connections = []BotConnectionConfig{{Model: "mimo-flash"}}
1124 normalizeLegacyMimoCustomProviders(c)
1125 NormalizeLegacyDesktopProviderAccess(c)
1126 access := desktopProviderAccessMap(c.Desktop.ProviderAccess)
1127 if !access["mimo-pro"] || !access["mimo-flash"] {
1128 t.Fatalf("provider_access = %+v, want unconfigured migrated MiMo refs visible", c.Desktop.ProviderAccess)
1129 }
1130 }
1131
1132 func TestBackfillDeepSeekOfficialPrices(t *testing.T) {
1133 c := &Config{Providers: []ProviderEntry{{
1134 Name: "deepseek",
1135 Kind: "openai",
1136 BaseURL: "https://api.deepseek.com",
1137 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1138 Default: "deepseek-v4-flash",
1139 }}}
1140 backfillDeepSeekOfficialPrices(c)
1141 p, ok := c.Provider("deepseek")
1142 if !ok {
1143 t.Fatal("deepseek provider missing")
1144 }
1145 if p.Prices["deepseek-v4-flash"].Output != 1.2 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 3.96 || p.Prices["deepseek-v4-pro"].Currency != "$" {
1146 t.Fatalf("deepseek prices = %+v, want default USD flash/pro prices", p.Prices)
1147 }
1148 }
1149
1150 func TestBackfillDeepSeekOfficialPricesUsesConfiguredLanguage(t *testing.T) {
1151 // Language no longer selects list-price tables; backfill uses billing_currency/USD.
1152 c := &Config{Language: "zh", Providers: []ProviderEntry{{
1153 Name: "deepseek",
1154 Kind: "openai",
1155 BaseURL: "https://api.deepseek.com",
1156 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1157 Default: "deepseek-v4-flash",
1158 }}}
1159 backfillDeepSeekOfficialPrices(c)
1160 p, ok := c.Provider("deepseek")
1161 if !ok {
1162 t.Fatal("deepseek provider missing")
1163 }
1164 if p.Prices["deepseek-v4-flash"].Output != 1.2 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 3.96 || p.Prices["deepseek-v4-pro"].Currency != "$" {
1165 t.Fatalf("deepseek prices = %+v, want USD official flash/pro prices", p.Prices)
1166 }
1167 }
1168
1169 func TestBackfillDeepSeekOfficialPricesKeepsProviderWidePrice(t *testing.T) {
1170 custom := &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"}
1171 c := &Config{Providers: []ProviderEntry{{
1172 Name: "deepseek",
1173 Kind: "openai",
1174 BaseURL: "https://api.deepseek.com",
1175 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1176 Default: "deepseek-v4-flash",
1177 Price: custom,
1178 Prices: map[string]*provider.Pricing{
1179 "deepseek-v4-flash": {CacheHit: 1, Input: 1, Output: 1, Currency: "$"},
1180 },
1181 }}}
1182 backfillDeepSeekOfficialPrices(c)
1183 p, ok := c.Provider("deepseek")
1184 if !ok {
1185 t.Fatal("deepseek provider missing")
1186 }
1187 if len(p.Prices) != 1 {
1188 t.Fatalf("deepseek prices = %+v, want existing per-model prices only", p.Prices)
1189 }
1190 pro, ok := c.ResolveModel("deepseek/deepseek-v4-pro")
1191 if !ok {
1192 t.Fatal("deepseek pro did not resolve")
1193 }
1194 if pro.Price == nil || pro.Price.Output != 9 {
1195 t.Fatalf("pro price = %+v, want provider-wide custom price", pro.Price)
1196 }
1197 flash, ok := c.ResolveModel("deepseek")
1198 if !ok {
1199 t.Fatal("deepseek default did not resolve")
1200 }
1201 if flash.Price == nil || flash.Price.Output != 1 {
1202 t.Fatalf("flash price = %+v, want existing per-model custom price", flash.Price)
1203 }
1204 }
1205
1206 func TestApplyDeepSeekOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T) {
1207 // Language must not rewrite frozen billing_currency list prices.
1208 c := Default()
1209 c.Language = "zh"
1210 applyDeepSeekOfficialDefaultPricing(c)
1211 flash, ok := c.Provider("deepseek-flash")
1212 if !ok {
1213 t.Fatal("deepseek-flash provider missing")
1214 }
1215 if flash.Price == nil || flash.Price.Output != 1.2 || flash.Price.Currency != "$" {
1216 t.Fatalf("flash price = %+v, want frozen USD default table", flash.Price)
1217 }
1218 pro, ok := c.Provider("deepseek-pro")
1219 if !ok {
1220 t.Fatal("deepseek-pro provider missing")
1221 }
1222 if pro.Price == nil || pro.Price.Output != 3.96 || pro.Price.Currency != "$" {
1223 t.Fatalf("pro price = %+v, want frozen USD default table", pro.Price)
1224 }
1225 }
1226
1227 func TestApplyDeepSeekOfficialDefaultPricingKeepsCustomPrice(t *testing.T) {
1228 c := &Config{Language: "zh", Providers: []ProviderEntry{{
1229 Name: "deepseek-flash",
1230 Kind: "openai",
1231 BaseURL: "https://api.deepseek.com",
1232 Model: "deepseek-v4-flash",
1233 Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
1234 }}}
1235 applyDeepSeekOfficialDefaultPricing(c)
1236 p, ok := c.Provider("deepseek-flash")
1237 if !ok {
1238 t.Fatal("deepseek-flash provider missing")
1239 }
1240 if p.Price == nil || p.Price.Output != 9 || p.Price.Currency != "$" {
1241 t.Fatalf("custom price = %+v, want unchanged", p.Price)
1242 }
1243 }
1244
1245 func TestLoadForEditAutoCurrencyKeepsPersistedOfficialUSDPrice(t *testing.T) {
1246 path := filepath.Join(t.TempDir(), "config.toml")
1247 body := `language = "zh"
1248
1249 [[providers]]
1250 name = "deepseek-flash"
1251 kind = "openai"
1252 base_url = "https://api.deepseek.com"
1253 model = "deepseek-v4-flash"
1254 price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
1255 `
1256 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
1257 t.Fatal(err)
1258 }
1259
1260 c := LoadForEdit(path)
1261 flash, ok := c.Provider("deepseek-flash")
1262 if !ok {
1263 t.Fatal("deepseek-flash provider missing")
1264 }
1265 if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
1266 t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
1267 }
1268
1269 if err := c.SetDesktopCurrency("CNY"); err != nil {
1270 t.Fatalf("SetDesktopCurrency CNY: %v", err)
1271 }
1272 // Display currency switch must freeze list prices (USD official table stays).
1273 if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
1274 t.Fatalf("flash price = %+v, want frozen USD official table", flash.Price)
1275 }
1276 if got := c.DisplayCurrencyPref(); got != "CNY" {
1277 t.Fatalf("display pref = %q, want CNY", got)
1278 }
1279 }
1280
1281 func TestLoadForRootAutoCurrencyKeepsPersistedOfficialUSDPrice(t *testing.T) {
1282 home := t.TempDir()
1283 t.Setenv("REASONIX_HOME", home)
1284 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
1285 body := `language = "zh"
1286
1287 [[providers]]
1288 name = "deepseek-flash"
1289 kind = "openai"
1290 base_url = "https://api.deepseek.com"
1291 model = "deepseek-v4-flash"
1292 price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
1293 `
1294 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o644); err != nil {
1295 t.Fatal(err)
1296 }
1297
1298 c, err := LoadForRoot(t.TempDir())
1299 if err != nil {
1300 t.Fatalf("LoadForRoot: %v", err)
1301 }
1302 flash, ok := c.Provider("deepseek-flash")
1303 if !ok {
1304 t.Fatal("deepseek-flash provider missing")
1305 }
1306 if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
1307 t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
1308 }
1309 }
1310
1311 func TestRuntimeAutoCurrencyKeepsPersistedOfficialUSDPrice(t *testing.T) {
1312 path := filepath.Join(t.TempDir(), "config.toml")
1313 body := `[[providers]]
1314 name = "deepseek-flash"
1315 kind = "openai"
1316 base_url = "https://api.deepseek.com"
1317 model = "deepseek-v4-flash"
1318 price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
1319 `
1320 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
1321 t.Fatal(err)
1322 }
1323
1324 c := LoadForEdit(path)
1325 flash, ok := c.Provider("deepseek-flash")
1326 if !ok {
1327 t.Fatal("deepseek-flash provider missing")
1328 }
1329 if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
1330 t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
1331 }
1332 }
1333
1334 func TestLoadForRootAutoCurrencyDoesNotMixPartialOfficialPrices(t *testing.T) {
1335 home := t.TempDir()
1336 t.Setenv("REASONIX_HOME", home)
1337 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
1338 body := `language = "zh"
1339
1340 [[providers]]
1341 name = "deepseek"
1342 kind = "openai"
1343 base_url = "https://api.deepseek.com"
1344 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1345 default = "deepseek-v4-flash"
1346
1347 [providers.prices]
1348 deepseek-v4-flash = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
1349 `
1350 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o644); err != nil {
1351 t.Fatal(err)
1352 }
1353
1354 c, err := LoadForRoot(t.TempDir())
1355 if err != nil {
1356 t.Fatalf("LoadForRoot: %v", err)
1357 }
1358 p, ok := c.Provider("deepseek")
1359 if !ok {
1360 t.Fatal("deepseek provider missing")
1361 }
1362 // Partial official USD prices freeze billing_currency=USD; missing pro row
1363 // backfills USD rather than mixing with language-selected CNY.
1364 for _, model := range p.Models {
1365 price := p.Prices[model]
1366 if price == nil || price.Currency != "$" {
1367 t.Fatalf("price for %q = %+v, want consistent USD table", model, price)
1368 }
1369 }
1370 }
1371
1372 func TestDeepSeekOfficialPricingCurrencyResolution(t *testing.T) {
1373 // Official list-price currency follows provider billing_currency, not display.
1374 for _, tt := range []struct {
1375 name string
1376 providers []ProviderEntry
1377 want string
1378 }{
1379 {name: "no providers defaults to USD", want: "USD"},
1380 {name: "explicit provider billing CNY", providers: []ProviderEntry{{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", BillingCurrency: "CNY"}}, want: "CNY"},
1381 {name: "explicit provider billing USD", providers: []ProviderEntry{{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", BillingCurrency: "USD"}}, want: "USD"},
1382 } {
1383 t.Run(tt.name, func(t *testing.T) {
1384 c := &Config{Providers: tt.providers}
1385 if got := c.DeepSeekOfficialPricingCurrency(); got != tt.want {
1386 t.Fatalf("pricing currency = %q, want %q", got, tt.want)
1387 }
1388 })
1389 }
1390 // Display currency is independent.
1391 c := &Config{Language: "zh", Desktop: DesktopConfig{Currency: "USD"}}
1392 if got := c.ResolveDisplayCurrency(); got != "USD" {
1393 t.Fatalf("display = %q, want USD", got)
1394 }
1395 c2 := &Config{Language: "zh"}
1396 if got := c2.ResolveDisplayCurrency(); got != "" {
1397 t.Fatalf("display auto zh = %q, want unresolved auto", got)
1398 }
1399 }
1400
1401 func TestLoadForRootKeepsPricingRegionUserGlobal(t *testing.T) {
1402 home := t.TempDir()
1403 project := t.TempDir()
1404 t.Setenv("REASONIX_HOME", home)
1405 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
1406 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[desktop]\nlanguage = \"en\"\ncurrency = \"USD\"\n"), 0o644); err != nil {
1407 t.Fatal(err)
1408 }
1409 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\nlanguage = \"zh\"\ncurrency = \"CNY\"\n"), 0o644); err != nil {
1410 t.Fatal(err)
1411 }
1412
1413 cfg, err := LoadForRoot(project)
1414 if err != nil {
1415 t.Fatalf("LoadForRoot: %v", err)
1416 }
1417 if got := cfg.DesktopCurrency(); got != "USD" {
1418 t.Fatalf("project config overrode user pricing currency: got %q, want USD", got)
1419 }
1420 if got := cfg.DesktopLanguage(); got != "en" {
1421 t.Fatalf("project config overrode user desktop language: got %q, want en", got)
1422 }
1423 }
1424
1425 func TestApplyDeepSeekOfficialDefaultPricingExplicitCurrencyWins(t *testing.T) {
1426 c := Default()
1427 c.Desktop.Language = "zh"
1428 c.Desktop.Currency = "USD"
1429 // Display currency no longer selects list-price tables; billing_currency does.
1430 flash, _ := c.Provider("deepseek-flash")
1431 flash.BillingCurrency = "USD"
1432 applyDeepSeekOfficialDefaultPricing(c)
1433 if flash.Price == nil || flash.Price.Output != 1.2 || flash.Price.Currency != "$" {
1434 t.Fatalf("flash price = %+v, want USD billing_currency table", flash.Price)
1435 }
1436
1437 c.Desktop.Language = "en"
1438 c.Desktop.Currency = "CNY"
1439 flash.BillingCurrency = "CNY"
1440 // Only refresh when price still matches an official default in the *new*
1441 // billing currency — force official CNY rates for this assertion.
1442 flash.Price = deepSeekV4FlashPriceCNY()
1443 applyDeepSeekOfficialDefaultPricing(c)
1444 if flash.Price == nil || flash.Price.Output != 8 || flash.Price.Currency != "¥" {
1445 t.Fatalf("flash price = %+v, want CNY billing_currency table", flash.Price)
1446 }
1447 }
1448
1449 func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
1450 path := filepath.Join(t.TempDir(), "reasonix.toml")
1451 c := &Config{
1452 ConfigVersion: 2,
1453 Providers: []ProviderEntry{
1454 {
1455 Name: "deepseek",
1456 Kind: "openai",
1457 BaseURL: "https://api.deepseek.com",
1458 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1459 Default: "deepseek-v4-flash",
1460 Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
1461 Prices: map[string]*provider.Pricing{
1462 "deepseek-v4-flash": {CacheHit: 8, Input: 8, Output: 8, Currency: "$"},
1463 },
1464 },
1465 {
1466 Name: "mimo-api",
1467 Kind: "openai",
1468 BaseURL: "https://api.xiaomimimo.com/v1",
1469 Models: []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"},
1470 Default: "mimo-v2.5-pro",
1471 Price: &provider.Pricing{CacheHit: 7, Input: 7, Output: 7, Currency: "$"},
1472 },
1473 },
1474 }
1475 if err := c.SaveTo(path); err != nil {
1476 t.Fatalf("SaveTo: %v", err)
1477 }
1478
1479 changed, err := ResetOfficialProviderPricingOnUpgrade(path)
1480 if err != nil {
1481 t.Fatalf("ResetOfficialProviderPricingOnUpgrade: %v", err)
1482 }
1483 if !changed {
1484 t.Fatal("upgrade reset did not run for config_version 2")
1485 }
1486 var got Config
1487 if _, err := toml.DecodeFile(path, &got); err != nil {
1488 t.Fatalf("decode migrated config: %v", err)
1489 }
1490 if got.ConfigVersion != Default().ConfigVersion {
1491 t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
1492 }
1493 deepseek, ok := got.Provider("deepseek")
1494 if !ok {
1495 t.Fatal("deepseek provider missing")
1496 }
1497 if deepseek.Price != nil {
1498 t.Fatalf("deepseek provider-wide price = %+v, want nil after reset", deepseek.Price)
1499 }
1500 if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Currency != "$" || p.Output != 1.2 {
1501 t.Fatalf("deepseek flash price = %+v, want USD default", p)
1502 }
1503 if p := deepseek.Prices["deepseek-v4-pro"]; p == nil || p.Currency != "$" || p.Output != 3.96 {
1504 t.Fatalf("deepseek pro price = %+v, want USD default", p)
1505 }
1506 mimo, ok := got.Provider("mimo-api")
1507 if !ok {
1508 t.Fatal("mimo-api provider missing")
1509 }
1510 if mimo.Price == nil || mimo.Price.Currency != "$" || mimo.Price.Output != 7 {
1511 t.Fatalf("mimo provider-wide price = %+v, want custom price preserved", mimo.Price)
1512 }
1513
1514 deepseek.Prices["deepseek-v4-flash"] = &provider.Pricing{CacheHit: 4, Input: 4, Output: 4, Currency: "$"}
1515 if err := got.SaveTo(path); err != nil {
1516 t.Fatalf("SaveTo after custom edit: %v", err)
1517 }
1518 changed, err = ResetOfficialProviderPricingOnUpgrade(path)
1519 if err != nil {
1520 t.Fatalf("second ResetOfficialProviderPricingOnUpgrade: %v", err)
1521 }
1522 if changed {
1523 t.Fatal("upgrade reset ran again after config_version was updated")
1524 }
1525 got = Config{}
1526 if _, err := toml.DecodeFile(path, &got); err != nil {
1527 t.Fatalf("decode custom config: %v", err)
1528 }
1529 deepseek, _ = got.Provider("deepseek")
1530 if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
1531 t.Fatalf("post-upgrade custom flash price = %+v, want preserved", p)
1532 }
1533 }
1534
1535 func TestApplyUserConfigUpgradesOnStartupVersion3NonWindowsAdvancesToV5(t *testing.T) {
1536 path := filepath.Join(t.TempDir(), "config.toml")
1537 setRuntimeGOOS(t, "darwin")
1538
1539 c := &Config{
1540 ConfigVersion: 3,
1541 Providers: []ProviderEntry{{
1542 Name: "deepseek",
1543 Kind: "openai",
1544 BaseURL: "https://api.deepseek.com",
1545 Models: []string{"deepseek-v4-flash"},
1546 Prices: map[string]*provider.Pricing{
1547 "deepseek-v4-flash": {CacheHit: 4, Input: 4, Output: 4, Currency: "$"},
1548 },
1549 }},
1550 }
1551 if err := c.SaveTo(path); err != nil {
1552 t.Fatalf("SaveTo: %v", err)
1553 }
1554
1555 changed, err := ApplyUserConfigUpgradesOnStartup(path)
1556 if err != nil {
1557 t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
1558 }
1559 if !changed {
1560 t.Fatal("v3 config should advance through the retired Auto Plan migration")
1561 }
1562 var got Config
1563 if _, err := toml.DecodeFile(path, &got); err != nil {
1564 t.Fatalf("decode migrated config: %v", err)
1565 }
1566 if got.ConfigVersion != Default().ConfigVersion {
1567 t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
1568 }
1569 deepseek, _ := got.Provider("deepseek")
1570 if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
1571 t.Fatalf("custom flash price = %+v, want preserved", p)
1572 }
1573 }
1574
1575 func TestApplyUserConfigUpgradesOnStartupWindowsBashEnforceDefaultsOffOnce(t *testing.T) {
1576 path := filepath.Join(t.TempDir(), "config.toml")
1577 setRuntimeGOOS(t, "windows")
1578
1579 c := Default()
1580 c.ConfigVersion = 3
1581 c.Sandbox.Bash = "enforce"
1582 if err := c.SaveTo(path); err != nil {
1583 t.Fatalf("SaveTo: %v", err)
1584 }
1585
1586 changed, err := ApplyUserConfigUpgradesOnStartup(path)
1587 if err != nil {
1588 t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
1589 }
1590 if !changed {
1591 t.Fatal("upgrade should migrate Windows bash sandbox default")
1592 }
1593 got := LoadForEdit(path)
1594 if got.ConfigVersion != Default().ConfigVersion {
1595 t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
1596 }
1597 if got.Sandbox.Bash != "off" || got.BashMode() != "off" {
1598 t.Fatalf("Windows bash mode after migration = raw %q effective %q, want off/off", got.Sandbox.Bash, got.BashMode())
1599 }
1600
1601 got.Sandbox.Bash = "enforce"
1602 if got.BashMode() != "off" {
1603 t.Fatalf("manual Windows enforce should still resolve off before save, got %q", got.BashMode())
1604 }
1605 if err := got.SaveTo(path); err != nil {
1606 t.Fatalf("SaveTo manual enforce: %v", err)
1607 }
1608 changed, err = ApplyUserConfigUpgradesOnStartup(path)
1609 if err != nil {
1610 t.Fatalf("second ApplyUserConfigUpgradesOnStartup: %v", err)
1611 }
1612 if changed {
1613 t.Fatal("v4 config should not be migrated again after user attempts to re-enable enforce")
1614 }
1615 got = LoadForEdit(path)
1616 if got.Sandbox.Bash != "off" || got.BashMode() != "off" {
1617 t.Fatalf("manual Windows enforce after save = raw %q effective %q, want off/off", got.Sandbox.Bash, got.BashMode())
1618 }
1619 }
1620
1621 func TestApplyUserConfigUpgradesOnStartupWindowsBashOffOnlyMarksVersion(t *testing.T) {
1622 path := filepath.Join(t.TempDir(), "config.toml")
1623 setRuntimeGOOS(t, "windows")
1624
1625 c := Default()
1626 c.ConfigVersion = 3
1627 c.Sandbox.Bash = "off"
1628 if err := c.SaveTo(path); err != nil {
1629 t.Fatalf("SaveTo: %v", err)
1630 }
1631
1632 changed, err := ApplyUserConfigUpgradesOnStartup(path)
1633 if err != nil {
1634 t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
1635 }
1636 if !changed {
1637 t.Fatal("Windows v3 config should be marked as migrated")
1638 }
1639 got := LoadForEdit(path)
1640 if got.ConfigVersion != Default().ConfigVersion {
1641 t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
1642 }
1643 if got.Sandbox.Bash != "off" || got.BashMode() != "off" {
1644 t.Fatalf("Windows bash mode after marker migration = raw %q effective %q, want off/off", got.Sandbox.Bash, got.BashMode())
1645 }
1646 }
1647
1648 func TestApplyUserConfigUpgradesOnStartupRetiresAutoPlan(t *testing.T) {
1649 path := filepath.Join(t.TempDir(), "config.toml")
1650 setRuntimeGOOS(t, "darwin")
1651 original := `config_version = 4
1652 default_model = "deepseek-flash"
1653
1654 [agent]
1655 auto_plan = "on"
1656 auto_plan_classifier = "deepseek-flash"
1657 temperature = 0.4
1658 `
1659 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
1660 t.Fatal(err)
1661 }
1662
1663 changed, err := ApplyUserConfigUpgradesOnStartup(path)
1664 if err != nil {
1665 t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
1666 }
1667 if !changed {
1668 t.Fatal("v4 auto-plan config should migrate to the manual-only experience")
1669 }
1670 raw, err := os.ReadFile(path)
1671 if err != nil {
1672 t.Fatal(err)
1673 }
1674 if strings.Contains(string(raw), "auto_plan") {
1675 t.Fatalf("retired auto-plan keys remain after migration:\n%s", raw)
1676 }
1677 got := LoadForEdit(path)
1678 if got.ConfigVersion != Default().ConfigVersion || got.Agent.AutoPlan != "off" || got.Agent.AutoPlanClassifier != "" {
1679 t.Fatalf("migrated config = version:%d auto:%q classifier:%q", got.ConfigVersion, got.Agent.AutoPlan, got.Agent.AutoPlanClassifier)
1680 }
1681 if got.Agent.Temperature != 0.4 {
1682 t.Fatalf("unrelated agent temperature = %v, want 0.4", got.Agent.Temperature)
1683 }
1684
1685 again, err := ApplyUserConfigUpgradesOnStartup(path)
1686 if err != nil {
1687 t.Fatalf("second ApplyUserConfigUpgradesOnStartup: %v", err)
1688 }
1689 if again {
1690 t.Fatal("v5 config should not migrate again")
1691 }
1692 }
1693
1694 func TestResolveModelUsesPerModelPricing(t *testing.T) {
1695 c := &Config{Providers: []ProviderEntry{{
1696 Name: "deepseek",
1697 Kind: "openai",
1698 BaseURL: "https://api.deepseek.com",
1699 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1700 Default: "deepseek-v4-flash",
1701 Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
1702 Prices: map[string]*provider.Pricing{
1703 "deepseek-v4-flash": &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"},
1704 "deepseek-v4-pro": &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"},
1705 },
1706 }}}
1707 pro, ok := c.ResolveModel("deepseek/deepseek-v4-pro")
1708 if !ok {
1709 t.Fatal("deepseek pro did not resolve")
1710 }
1711 if pro.Price == nil || pro.Price.Output != 6 {
1712 t.Fatalf("pro price = %+v, want model-specific pro price", pro.Price)
1713 }
1714 flash, ok := c.ResolveModel("deepseek")
1715 if !ok {
1716 t.Fatal("deepseek default did not resolve")
1717 }
1718 if flash.Price == nil || flash.Price.Output != 2 {
1719 t.Fatalf("flash price = %+v, want model-specific flash price", flash.Price)
1720 }
1721 }
1722
1723 func TestNormalizeLegacyMimoProviderCatalogsBackfillsOfficialMimoAPIStub(t *testing.T) {
1724 c := &Config{
1725 DefaultModel: "mimo-api/mimo-v2.5-pro",
1726 Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
1727 Providers: []ProviderEntry{{
1728 Name: "mimo-api",
1729 Kind: "openai",
1730 BaseURL: "https://api.xiaomimimo.com/v1",
1731 Model: "mimo-v2.5-pro",
1732 APIKeyEnv: "MIMO_API_KEY",
1733 }},
1734 }
1735
1736 normalizeDesktopOfficialProviderAccess(c)
1737
1738 p, ok := c.Provider("mimo-api")
1739 if !ok {
1740 t.Fatal("mimo-api provider missing")
1741 }
1742 wantModels := []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}
1743 if !reflect.DeepEqual(p.ModelList(), wantModels) {
1744 t.Fatalf("mimo-api models = %v, want %v", p.ModelList(), wantModels)
1745 }
1746 if p.Default != "mimo-v2.5-pro" {
1747 t.Fatalf("mimo-api default = %q, want mimo-v2.5-pro", p.Default)
1748 }
1749 if !reflect.DeepEqual(p.VisionModels, []string{"mimo-v2.5", "mimo-v2-omni"}) {
1750 t.Fatalf("mimo-api vision_models = %v, want vision-capable MiMo models", p.VisionModels)
1751 }
1752 }
1753
1754 func TestNormalizeDesktopOfficialProviderAccessDoesNotBackfillCustomNamedMimoAPI(t *testing.T) {
1755 c := &Config{
1756 Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
1757 Providers: []ProviderEntry{{
1758 Name: "mimo-api",
1759 Kind: "openai",
1760 BaseURL: "https://proxy.example.com/v1",
1761 Model: "mimo-v2.5-pro",
1762 }},
1763 }
1764
1765 normalizeDesktopOfficialProviderAccess(c)
1766
1767 p, ok := c.Provider("mimo-api")
1768 if !ok {
1769 t.Fatal("mimo-api provider missing")
1770 }
1771 if p.HasModel("mimo-v2.5") || p.HasModel("mimo-v2-omni") {
1772 t.Fatalf("custom mimo-api models = %v, want original custom list", p.ModelList())
1773 }
1774 }
1775
1776 func TestNormalizeLegacyMimoProviderCatalogsBackfillsOfficialMimoTokenPlanStub(t *testing.T) {
1777 c := &Config{
1778 Desktop: DesktopConfig{ProviderAccess: []string{"mimo-token-plan"}},
1779 Providers: []ProviderEntry{{
1780 Name: "mimo-token-plan",
1781 Kind: "openai",
1782 BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
1783 Model: "mimo-v2.5-pro",
1784 APIKeyEnv: "MIMO_API_KEY",
1785 Price: &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"},
1786 }},
1787 }
1788
1789 normalizeDesktopOfficialProviderAccess(c)
1790
1791 p, ok := c.Provider("mimo-token-plan")
1792 if !ok {
1793 t.Fatal("mimo-token-plan provider missing")
1794 }
1795 if !reflect.DeepEqual(p.ModelList(), []string{"mimo-v2.5-pro", "mimo-v2.5"}) {
1796 t.Fatalf("mimo-token-plan models = %v, want token plan catalog", p.ModelList())
1797 }
1798 if p.Price == nil || p.Price.Currency != "CNY" {
1799 t.Fatalf("mimo-token-plan price = %+v, want preserved custom provider-wide price", p.Price)
1800 }
1801 if !reflect.DeepEqual(p.VisionModels, []string{"mimo-v2.5"}) {
1802 t.Fatalf("mimo-token-plan vision_models = %v, want token plan vision metadata", p.VisionModels)
1803 }
1804 }
1805
1806 // ── Explicit model list: normalization must not override user selection ───────
1807
1808 func TestBackfillDeepSeekProSkipsWhenExplicitModelList(t *testing.T) {
1809 // User saved with only flash via Settings → Models = ["deepseek-v4-flash"].
1810 c := &Config{Providers: []ProviderEntry{
1811 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
1812 }}
1813 backfillDeepSeekPro(c)
1814 if hasModel(c, "deepseek-v4-pro") != nil {
1815 t.Fatal("deepseek-v4-pro must not be added when user has an explicit model list")
1816 }
1817 if len(c.Providers) != 1 {
1818 t.Fatalf("providers = %d, want 1 (no new entry should be added)", len(c.Providers))
1819 }
1820 }
1821
1822 func TestEnsureProviderModelsSkipsWhenExplicitModelList(t *testing.T) {
1823 // User saved with only flash via Settings → Models = ["deepseek-v4-flash"].
1824 p := &ProviderEntry{
1825 Name: "deepseek",
1826 BaseURL: "https://api.deepseek.com",
1827 Models: []string{"deepseek-v4-flash"},
1828 Default: "deepseek-v4-flash",
1829 }
1830 ensureProviderModels(p, []string{"deepseek-v4-flash", "deepseek-v4-pro"}, "deepseek-v4-flash")
1831 if p.HasModel("deepseek-v4-pro") {
1832 t.Fatal("ensureProviderModels must not merge required models when Models is explicitly set")
1833 }
1834 if len(p.Models) != 1 || p.Models[0] != "deepseek-v4-flash" {
1835 t.Fatalf("models = %v, want [deepseek-v4-flash]", p.Models)
1836 }
1837 }
1838
1839 func TestMergeCuratedModelsIntoProviderSkipsWhenExplicitModelList(t *testing.T) {
1840 // User saved with only two mimo models via Settings.
1841 p := &ProviderEntry{
1842 Name: "mimo-api",
1843 BaseURL: "https://api.xiaomimimo.com/v1",
1844 Models: []string{"mimo-v2.5-pro", "mimo-v2.5"},
1845 Default: "mimo-v2.5-pro",
1846 }
1847 mergeCuratedModelsIntoProvider(p, []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}, "mimo-v2.5-pro")
1848 if p.HasModel("mimo-v2-omni") {
1849 t.Fatal("mergeCuratedModelsIntoProvider must not add mimo-v2-omni when Models is explicitly set")
1850 }
1851 if len(p.Models) != 2 {
1852 t.Fatalf("models = %v, want 2 selected models", p.Models)
1853 }
1854 }
1855
1856 func TestNormalizeOfficialMimoVisionModelsSkipsExplicitModelList(t *testing.T) {
1857 // User saved with only pro via Settings → Models = ["mimo-v2.5-pro"].
1858 c := &Config{
1859 Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
1860 Providers: []ProviderEntry{{
1861 Name: "mimo-api",
1862 Kind: "openai",
1863 BaseURL: "https://api.xiaomimimo.com/v1",
1864 Models: []string{"mimo-v2.5-pro"},
1865 Default: "mimo-v2.5-pro",
1866 APIKeyEnv: "MIMO_API_KEY",
1867 }},
1868 }
1869 normalizeDesktopOfficialProviderAccess(c)
1870 p, ok := c.Provider("mimo-api")
1871 if !ok {
1872 t.Fatal("mimo-api provider missing")
1873 }
1874 if p.HasModel("mimo-v2.5") || p.HasModel("mimo-v2-omni") {
1875 t.Fatalf("mimo-api models = %v, want only explicitly selected pro model", p.ModelList())
1876 }
1877 if len(p.VisionModels) != 0 {
1878 t.Fatalf("mimo-api vision_models = %v, want empty for pro-only explicit model list", p.VisionModels)
1879 }
1880 }
1881
1882 func TestNormalizeOfficialMimoVisionModelsPreservesExplicitEmptyList(t *testing.T) {
1883 c := &Config{
1884 Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
1885 Providers: []ProviderEntry{{
1886 Name: "mimo-api",
1887 Kind: "openai",
1888 BaseURL: "https://api.xiaomimimo.com/v1",
1889 Models: []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"},
1890 Default: "mimo-v2.5-pro",
1891 APIKeyEnv: "MIMO_API_KEY",
1892 VisionModels: []string{},
1893 }},
1894 }
1895 normalizeDesktopOfficialProviderAccess(c)
1896 p, ok := c.Provider("mimo-api")
1897 if !ok {
1898 t.Fatal("mimo-api provider missing")
1899 }
1900 if p.VisionModels == nil || len(p.VisionModels) != 0 {
1901 t.Fatalf("mimo-api vision_models = %#v, want explicit empty list", p.VisionModels)
1902 }
1903 }
1904
1905 func TestNormalizeOfficialDeepSeekModelsSkipsExplicitModelList(t *testing.T) {
1906 // User saved with only flash via Settings → Models = ["deepseek-v4-flash"].
1907 c := &Config{Providers: []ProviderEntry{{
1908 Name: "deepseek",
1909 Kind: "openai",
1910 BaseURL: "https://api.deepseek.com",
1911 Models: []string{"deepseek-v4-flash"},
1912 Default: "deepseek-v4-flash",
1913 APIKeyEnv: "DEEPSEEK_API_KEY",
1914 }}}
1915 normalizeOfficialDeepSeekModels(c)
1916 p, ok := c.Provider("deepseek")
1917 if !ok {
1918 t.Fatal("deepseek provider missing")
1919 }
1920 if p.HasModel("deepseek-v4-pro") {
1921 t.Fatal("normalizeOfficialDeepSeekModels must not add pro when Models is explicitly set")
1922 }
1923 }
1924
1925 func TestNormalizeLegacyOpenCodeGoVisionCatalogMigratesOnlyUntouchedPreset(t *testing.T) {
1926 base := ProviderEntry{
1927 Name: "opencode-go", Kind: "openai", BaseURL: "https://opencode.ai/zen/go/v1",
1928 Models: append([]string(nil), preVisionOpenCodeGoModels...), VisionModels: []string{"kimi-k3"},
1929 Default: "glm-5.2", PresetID: "opencode-go",
1930 }
1931 custom := base
1932 custom.Models = append(custom.Models, "private-model")
1933 explicit := base
1934 explicit.VisionModels = []string{}
1935 c := &Config{Providers: []ProviderEntry{base, custom, explicit}}
1936 if !normalizeLegacyOpenCodeGoVisionCatalog(c) {
1937 t.Fatal("untouched OpenCode Go catalog was not migrated")
1938 }
1939 if !c.Providers[0].HasModel(openai.OfficialDeepSeekVisionModel) || !c.Providers[0].HasVisionModel(openai.OfficialDeepSeekVisionModel) {
1940 t.Fatalf("migrated catalog = %+v", c.Providers[0])
1941 }
1942 if c.Providers[1].HasModel(openai.OfficialDeepSeekVisionModel) {
1943 t.Fatal("custom model catalog was unexpectedly migrated")
1944 }
1945 if !c.Providers[2].HasModel(openai.OfficialDeepSeekVisionModel) || len(c.Providers[2].VisionModels) != 0 {
1946 t.Fatalf("explicit no-vision catalog = %+v", c.Providers[2])
1947 }
1948 }
1949
1949 lines GO