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