| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/BurntSushi/toml" |
| 12 | |
| 13 | "reasonix/internal/fileutil" |
| 14 | ) |
| 15 | |
| 16 | func TestMimoCatalogUpgradePreservesSelectionsAndDeletion(t *testing.T) { |
| 17 | raw := `config_version = 11 # schema |
| 18 | default_model = "mimo-api/mimo-v2.5-pro" |
| 19 | future_root = { choice = "keep" } |
| 20 | [[providers]] |
| 21 | name = "mimo-api" |
| 22 | kind = "openai" |
| 23 | base_url = "https://api.xiaomimimo.com/v1" |
| 24 | models = ["custom-ID", "mimo-v2.5-pro", "mimo-v2.5"] |
| 25 | default = "mimo-v2.5-pro" |
| 26 | api_key_env = "USER_MIMO_KEY" |
| 27 | vision_models = ["mimo-v2.5"] |
| 28 | future_provider = { value = "keep" } |
| 29 | [providers.prices.mimo-v2.5-pro] |
| 30 | cache_hit = 0.025 |
| 31 | input = 3.0 |
| 32 | output = 6.0 |
| 33 | currency = "¥" |
| 34 | ` |
| 35 | path := filepath.Join(t.TempDir(), "config.toml") |
| 36 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || !changed { |
| 40 | t.Fatalf("upgrade=%v err=%v", changed, err) |
| 41 | } |
| 42 | cfg := LoadForEdit(path) |
| 43 | p, ok := cfg.Provider("mimo-api") |
| 44 | if !ok { |
| 45 | t.Fatal("mimo-api missing after upgrade") |
| 46 | } |
| 47 | wantModels := []string{"custom-ID", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2.6-pro", "mimo-v2.6-flash"} |
| 48 | wantVision := []string{"mimo-v2.5", "mimo-v2.6-pro", "mimo-v2.6-flash"} |
| 49 | if cfg.ConfigVersion != Default().ConfigVersion || !reflect.DeepEqual(p.Models, wantModels) || |
| 50 | !reflect.DeepEqual(p.VisionModels, wantVision) || p.DefaultModel() != "mimo-v2.5-pro" || |
| 51 | cfg.DefaultModel != "mimo-api/mimo-v2.5-pro" || p.APIKeyEnv != "USER_MIMO_KEY" { |
| 52 | t.Fatalf("lost settings: version=%d default=%s provider=%+v", cfg.ConfigVersion, cfg.DefaultModel, p) |
| 53 | } |
| 54 | if price := p.PriceForModel("mimo-v2.6-flash"); price == nil || price.Input != 1 || price.Output != 2 || price.CacheHit != 0.02 { |
| 55 | t.Fatalf("mimo-v2.6-flash price = %+v", price) |
| 56 | } |
| 57 | before, err := os.ReadFile(path) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | for _, kept := range []string{`# schema`, `future_root = { choice = "keep" }`, `future_provider = { value = "keep" }`} { |
| 62 | if !strings.Contains(string(before), kept) { |
| 63 | t.Fatalf("lost %s", kept) |
| 64 | } |
| 65 | } |
| 66 | p.Models = []string{"custom-ID", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2.6-pro"} |
| 67 | if err := cfg.SaveTo(path); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | before, _ = os.ReadFile(path) |
| 71 | for range 2 { |
| 72 | if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || changed { |
| 73 | t.Fatalf("reapplied upgrade: %v %v", changed, err) |
| 74 | } |
| 75 | } |
| 76 | after, _ := os.ReadFile(path) |
| 77 | if string(after) != string(before) || LoadForEdit(path).Providers[0].HasModel("mimo-v2.6-flash") { |
| 78 | t.Fatal("deleted MiMo model reappeared") |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestMimoCatalogUpgradeScopeAndCapabilities(t *testing.T) { |
| 83 | for _, tc := range []struct { |
| 84 | name, kind, base, extra string |
| 85 | add, addVision bool |
| 86 | }{ |
| 87 | {"payg openai", "openai", "https://api.xiaomimimo.com/v1", `models = ["mimo-v2.5-pro", "mimo-v2.5"] |
| 88 | vision_models = ["mimo-v2.5"]`, true, true}, |
| 89 | {"payg anthropic", "anthropic", "https://api.xiaomimimo.com/anthropic", `models = ["mimo-v2.5-pro"]`, true, false}, |
| 90 | {"token Singapore", "openai", "https://token-plan-sgp.xiaomimimo.com/v1", `models = ["mimo-v2.5"]`, true, false}, |
| 91 | {"token Europe", "anthropic", "https://token-plan-ams.xiaomimimo.com/anthropic", `models = ["mimo-v2.5-pro"]`, true, false}, |
| 92 | {"explicit vision off", "openai", "https://token-plan-cn.xiaomimimo.com/v1", `models = ["mimo-v2.5"] |
| 93 | vision_models = []`, true, false}, |
| 94 | {"custom endpoint", "openai", "https://relay.example/v1", `models = ["mimo-v2.5-pro"]`, false, false}, |
| 95 | {"custom request", "openai", "https://api.xiaomimimo.com/v1", `models = ["mimo-v2.5-pro"] |
| 96 | request_url = "https://api.xiaomimimo.com/custom"`, false, false}, |
| 97 | {"unrelated catalog", "openai", "https://api.xiaomimimo.com/v1", `models = ["custom-model"]`, false, false}, |
| 98 | } { |
| 99 | t.Run(tc.name, func(t *testing.T) { |
| 100 | raw := "config_version = 11\n[[providers]]\nname = 'mimo'\nkind = '" + tc.kind + "'\nbase_url = '" + tc.base + "'\n" + tc.extra + "\n" |
| 101 | next, changed, err := rewriteMimoCatalogUpgrade(raw) |
| 102 | if err != nil || !changed { |
| 103 | t.Fatalf("changed=%v err=%v", changed, err) |
| 104 | } |
| 105 | var cfg Config |
| 106 | if _, err := toml.Decode(next, &cfg); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | p := cfg.Providers[0] |
| 110 | if p.HasModel("mimo-v2.6-pro") != tc.add || p.HasModel("mimo-v2.6-flash") != tc.add { |
| 111 | t.Fatalf("models = %v, add=%v", p.ModelList(), tc.add) |
| 112 | } |
| 113 | if p.HasVisionModel("mimo-v2.6-pro") != tc.addVision || p.HasVisionModel("mimo-v2.6-flash") != tc.addVision { |
| 114 | t.Fatalf("vision models = %v, add=%v", p.VisionModels, tc.addVision) |
| 115 | } |
| 116 | }) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestMimoCatalogUpgradeSingularPriceAndAtomicRetry(t *testing.T) { |
| 121 | raw := `config_version = 11 |
| 122 | [[providers]] |
| 123 | name = "mimo-token-plan-cn" |
| 124 | kind = "openai" |
| 125 | base_url = "https://token-plan-cn.xiaomimimo.com/v1" |
| 126 | model = "mimo-v2.5-pro" |
| 127 | price = { cache_hit = 9.0, input = 9.0, output = 9.0, currency = "¥" } |
| 128 | ` |
| 129 | path := filepath.Join(t.TempDir(), "config.toml") |
| 130 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | failedWrite := func(string, []byte, os.FileMode) error { return errors.New("interrupted") } |
| 134 | if changed, err := upgradeMimoCatalogFileLocked(path, failedWrite); err == nil || changed { |
| 135 | t.Fatalf("interrupted upgrade = %v, %v", changed, err) |
| 136 | } |
| 137 | unchanged, _ := os.ReadFile(path) |
| 138 | if string(unchanged) != raw { |
| 139 | t.Fatal("failed migration changed the original file") |
| 140 | } |
| 141 | if changed, err := upgradeMimoCatalogFileLocked(path, fileutil.AtomicWriteFile); err != nil || !changed { |
| 142 | t.Fatalf("retry = %v, %v", changed, err) |
| 143 | } |
| 144 | cfg := LoadForEdit(path) |
| 145 | p := &cfg.Providers[0] |
| 146 | if p.DefaultModel() != "mimo-v2.5-pro" { |
| 147 | t.Fatalf("singular default changed to %q", p.DefaultModel()) |
| 148 | } |
| 149 | for model, wantInput := range map[string]float64{"mimo-v2.6-pro": 3, "mimo-v2.6-flash": 1} { |
| 150 | if price := p.PriceForModel(model); price == nil || price.Input != wantInput { |
| 151 | t.Fatalf("%s inherited singular price: %+v", model, price) |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestMimoCatalogUpgradeTOMLRepresentations(t *testing.T) { |
| 157 | for name, raw := range map[string]string{ |
| 158 | "inline providers and prices": `providers = [{name="mimo", kind="openai", base_url="https://api.xiaomimimo.com/v1", models=["mimo-v2.5"], prices={"mimo-v2.5"={input=9.0, future="keep"}}}]`, |
| 159 | "inline prices": `[[providers]] |
| 160 | name="mimo" |
| 161 | kind="openai" |
| 162 | base_url="https://api.xiaomimimo.com/v1" |
| 163 | models=["mimo-v2.5"] |
| 164 | prices={"mimo-v2.5"={input=9.0, future="keep"}} |
| 165 | `, |
| 166 | "nested prices and adjacent providers": `[[providers]] |
| 167 | name="mimo" |
| 168 | kind="openai" |
| 169 | base_url="https://api.xiaomimimo.com/v1" |
| 170 | models=["mimo-v2.5"] |
| 171 | [providers.prices."mimo-v2.5"] |
| 172 | input=9.0 |
| 173 | future="keep" |
| 174 | [[providers]] |
| 175 | name="other" |
| 176 | kind="openai" |
| 177 | base_url="https://relay.example/v1" |
| 178 | models=["custom"] |
| 179 | [providers.prices.custom] |
| 180 | input=7.0 |
| 181 | [future] |
| 182 | value="keep" |
| 183 | `, |
| 184 | } { |
| 185 | t.Run(name, func(t *testing.T) { |
| 186 | next, changed, err := rewriteMimoCatalogUpgrade("config_version=11\n" + raw) |
| 187 | if err != nil || !changed { |
| 188 | t.Fatalf("changed=%v err=%v", changed, err) |
| 189 | } |
| 190 | var cfg Config |
| 191 | if _, err := toml.Decode(next, &cfg); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | p := cfg.Providers[0] |
| 195 | if !p.HasModel("mimo-v2.6-pro") || !p.HasModel("mimo-v2.6-flash") || p.Prices["mimo-v2.5"].Input != 9 { |
| 196 | t.Fatalf("unexpected migrated provider: %+v", p) |
| 197 | } |
| 198 | if _, changed, err := rewriteMimoCatalogUpgrade(next); err != nil || changed { |
| 199 | t.Fatalf("second upgrade changed=%v err=%v", changed, err) |
| 200 | } |
| 201 | }) |
| 202 | } |
| 203 | } |
| 204 |