| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "reflect" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/BurntSushi/toml" |
| 14 | |
| 15 | "reasonix/internal/fileutil" |
| 16 | ) |
| 17 | |
| 18 | func TestDeepSeekCatalogUpgradePreservesSelectionsAndDeletion(t *testing.T) { |
| 19 | for _, kind := range []string{"openai", "anthropic", "responses"} { |
| 20 | for _, singular := range []bool{false, true} { |
| 21 | t.Run(fmt.Sprintf("%s/singular=%v", kind, singular), func(t *testing.T) { |
| 22 | base := "https://api.deepseek.com" |
| 23 | if kind == "anthropic" { |
| 24 | base += "/anthropic" |
| 25 | } |
| 26 | models := `models = ["custom-ID", "deepseek-v4-pro", "deepseek-v4-flash"]` |
| 27 | want := []string{"custom-ID", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"} |
| 28 | if singular { |
| 29 | models = `model = "deepseek-v4-pro"` |
| 30 | want = []string{"deepseek-v4-pro", "deepseek-flash"} |
| 31 | } |
| 32 | raw := fmt.Sprintf(`config_version = 10 # schema |
| 33 | default_model = "renamed/deepseek-v4-pro" |
| 34 | future_root = { choice = "keep" } |
| 35 | [[providers]] |
| 36 | name = "renamed" |
| 37 | kind = %q |
| 38 | base_url = %q |
| 39 | %s |
| 40 | default = "deepseek-v4-pro" |
| 41 | api_key_env = "USER_DEEPSEEK_KEY" |
| 42 | vision_models = [] |
| 43 | web_search = false |
| 44 | future_provider = { value = "keep" } |
| 45 | [providers.model_overrides.deepseek-v4-pro] |
| 46 | default_effort = "max" |
| 47 | future_override = 42 |
| 48 | `, kind, base, models) |
| 49 | path := filepath.Join(t.TempDir(), "config.toml") |
| 50 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || !changed { |
| 54 | t.Fatalf("upgrade=%v err=%v", changed, err) |
| 55 | } |
| 56 | var cfg Config |
| 57 | if _, err := decodeTOMLFile(path, &cfg); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | p := cfg.Providers[0] |
| 61 | if cfg.ConfigVersion != Default().ConfigVersion || !reflect.DeepEqual(p.Models, want) || |
| 62 | p.DefaultModel() != "deepseek-v4-pro" || cfg.DefaultModel != "renamed/deepseek-v4-pro" || |
| 63 | p.Kind != kind || p.APIKeyEnv != "USER_DEEPSEEK_KEY" || len(p.VisionModels) != 0 || *p.WebSearch { |
| 64 | t.Fatalf("lost settings: version=%d default=%s provider=%+v", cfg.ConfigVersion, cfg.DefaultModel, p) |
| 65 | } |
| 66 | loaded := LoadForEdit(path) |
| 67 | entry, ok := loaded.ResolveModel("renamed/deepseek-v4-pro") |
| 68 | if !ok || entry.Model != "deepseek-v4-pro" || entry.DefaultEffort != "max" { |
| 69 | t.Fatalf("old selection changed: %+v %v", entry, ok) |
| 70 | } |
| 71 | before, _ := os.ReadFile(path) |
| 72 | for _, kept := range []string{`# schema`, `future_root = { choice = "keep" }`, `future_provider = { value = "keep" }`, `future_override = 42`} { |
| 73 | if !strings.Contains(string(before), kept) { |
| 74 | t.Fatalf("lost %s", kept) |
| 75 | } |
| 76 | } |
| 77 | // Save through the ordinary settings writer after removing the new |
| 78 | // option. The version travels with the user's remaining choices. |
| 79 | loaded.Providers[0].Models = want[:len(want)-1] |
| 80 | if err := loaded.SaveTo(path); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | before, _ = os.ReadFile(path) |
| 84 | for range 2 { |
| 85 | if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || changed { |
| 86 | t.Fatalf("reapplied upgrade: %v %v", changed, err) |
| 87 | } |
| 88 | } |
| 89 | after, _ := os.ReadFile(path) |
| 90 | if string(after) != string(before) || LoadForEdit(path).Providers[0].HasModel("deepseek-flash") { |
| 91 | t.Fatal("deleted option reappeared") |
| 92 | } |
| 93 | }) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestDeepSeekCatalogUpgradeScopeAndDefaults(t *testing.T) { |
| 99 | for _, tc := range []struct { |
| 100 | name, base, extra string |
| 101 | add bool |
| 102 | }{ |
| 103 | {"implicit default", "https://api.deepseek.com", `models = ["deepseek-v4-pro"]`, true}, |
| 104 | {"already current", "https://api.deepseek.com", `models = ["deepseek-v4-pro", "deepseek-flash"]`, false}, |
| 105 | {"empty list", "https://api.deepseek.com", `models = []`, false}, |
| 106 | {"custom endpoint", "https://api.deepseek.com", "models = [\"deepseek-v4-pro\"]\nrequest_url = \"https://api.deepseek.com/custom/chat/completions\"", false}, |
| 107 | {"third party", "https://relay.example/v1", `models = ["deepseek-v4-pro"]`, false}, |
| 108 | {"lookalike host", "https://api.deepseek.com.evil.test", `models = ["deepseek-v4-pro"]`, false}, |
| 109 | } { |
| 110 | t.Run(tc.name, func(t *testing.T) { |
| 111 | raw := fmt.Sprintf("config_version = 10\n[[providers]]\nname = 'deepseek'\nkind = 'openai'\nbase_url = %q\n%s\n", tc.base, tc.extra) |
| 112 | next, changed, err := rewriteDeepSeekCatalogUpgrade(raw) |
| 113 | if err != nil || !changed { |
| 114 | t.Fatalf("%v %v", changed, err) |
| 115 | } |
| 116 | var before, after Config |
| 117 | _, _ = toml.Decode(raw, &before) |
| 118 | _, _ = toml.Decode(next, &after) |
| 119 | want := before.Providers[0].ModelList() |
| 120 | if tc.add { |
| 121 | want = append(want, "deepseek-flash") |
| 122 | } |
| 123 | if !reflect.DeepEqual(want, after.Providers[0].ModelList()) || before.Providers[0].DefaultModel() != after.Providers[0].DefaultModel() { |
| 124 | t.Fatalf("unexpected models/default: %s", next) |
| 125 | } |
| 126 | }) |
| 127 | } |
| 128 | for _, raw := range []string{"config_version = 10\n", "config_version = 11\n", "config_version = 999\n"} { |
| 129 | next, _, err := rewriteDeepSeekCatalogUpgrade(raw) |
| 130 | if err != nil || (raw != "config_version = 10\n" && next != raw) { |
| 131 | t.Fatalf("version-only %q: %q %v", raw, next, err) |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestDeepSeekCatalogUpgradeKeepsLegacySingularDefault(t *testing.T) { |
| 137 | // Older pricing/layout upgrades load and save before the catalog migration. |
| 138 | // Normalization there must not silently switch a provider-only selection. |
| 139 | for _, version := range []int{5, 10} { |
| 140 | path := filepath.Join(t.TempDir(), "config.toml") |
| 141 | raw := fmt.Sprintf(`config_version = %d |
| 142 | default_model = "deepseek-flash" |
| 143 | [[providers]] |
| 144 | name = "deepseek-flash" |
| 145 | kind = "openai" |
| 146 | base_url = "https://api.deepseek.com" |
| 147 | model = "deepseek-v4-flash" |
| 148 | [desktop] |
| 149 | layout_style = "classic" |
| 150 | `, version) |
| 151 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | c := LoadForEdit(path) |
| 158 | p, ok := c.ResolveModel(c.DefaultModel) |
| 159 | if !ok || p.Model != "deepseek-v4-flash" || !p.HasModel("deepseek-flash") { |
| 160 | t.Fatalf("v%d switched the existing selection: %+v", version, p) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestDeepSeekCatalogUpgradeAtomicRetryAndUnknownInlineFields(t *testing.T) { |
| 166 | path := filepath.Join(t.TempDir(), "config.toml") |
| 167 | raw := "\ufeff" + strings.ReplaceAll(`config_version = 10 # version |
| 168 | providers = [{name='deepseek-pro',kind='openai',base_url='https://api.deepseek.com',model='deepseek-v4-pro',price={cache_hit=0.3,input=9,output=27,currency='¥'},future={date=2026-09-21,array=[{a='b'}]}}] # keep inline |
| 169 | [desktop] |
| 170 | future = "keep" |
| 171 | `, "\n", "\r\n") |
| 172 | if err := os.WriteFile(path, []byte(raw), 0o640); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | failure := errors.New("interrupted before rename") |
| 176 | if changed, err := upgradeDeepSeekCatalogFileLocked(path, func(_ string, proposed []byte, _ os.FileMode) error { |
| 177 | if !strings.Contains(string(proposed), "deepseek-flash") || !strings.Contains(string(proposed), "config_version = 11") { |
| 178 | t.Fatal("models and marker were not in the same commit") |
| 179 | } |
| 180 | return failure |
| 181 | }); changed || !errors.Is(err, failure) { |
| 182 | t.Fatalf("failed commit: %v %v", changed, err) |
| 183 | } |
| 184 | unchanged, _ := os.ReadFile(path) |
| 185 | if string(unchanged) != raw { |
| 186 | t.Fatal("interruption partially modified config") |
| 187 | } |
| 188 | if changed, err := upgradeDeepSeekCatalogFileLocked(path, fileutil.AtomicWriteFile); err != nil || !changed { |
| 189 | t.Fatalf("retry: %v %v", changed, err) |
| 190 | } |
| 191 | got, _ := os.ReadFile(path) |
| 192 | if !strings.HasPrefix(string(got), "\ufeff") || strings.Contains(strings.ReplaceAll(string(got), "\r\n", ""), "\n") || !strings.Contains(string(got), "# keep inline") { |
| 193 | t.Fatalf("lost encoding or comments: %q", got) |
| 194 | } |
| 195 | info, _ := os.Stat(path) |
| 196 | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { |
| 197 | t.Fatalf("changed permissions: %o", info.Mode().Perm()) |
| 198 | } |
| 199 | var c Config |
| 200 | if _, err := decodeTOMLFile(path, &c); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | p := c.Providers[0] |
| 204 | if p.Model != "deepseek-v4-pro" || p.DefaultModel() != "deepseek-v4-pro" || p.Price.Input != 9 || !reflect.DeepEqual(p.Prices["deepseek-flash"], deepSeekV4FlashPriceCNY()) { |
| 205 | t.Fatalf("price/default provenance lost: %+v", p) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestDeepSeekCatalogUpgradePreservesCustomFlashPriceAndNestedTables(t *testing.T) { |
| 210 | raw := `config_version = 10 |
| 211 | [[providers]] |
| 212 | name = 'pro' |
| 213 | kind = 'openai' |
| 214 | base_url = 'https://api.deepseek.com' |
| 215 | models = [ |
| 216 | 'deepseek-v4-pro', |
| 217 | ] # keep selection comment |
| 218 | price = {input=9, output=27, currency='¥'} |
| 219 | [providers.prices."deepseek-flash"] |
| 220 | input = 123.4 # custom new-model rate |
| 221 | future_price = true |
| 222 | [providers.model_overrides."deepseek-v4-pro"] |
| 223 | context_window = 12345 |
| 224 | [[providers]] |
| 225 | name = 'second' |
| 226 | kind = 'responses' |
| 227 | base_url = 'https://api.deepseek.com' |
| 228 | models = ['deepseek-v4-flash'] |
| 229 | price = {input=9, output=27, currency='¥'} |
| 230 | prices = { 'deepseek-v4-flash' = {input=123.0, currency='¥'} } |
| 231 | [desktop] |
| 232 | future = '''[[providers]] |
| 233 | fake header''' |
| 234 | ` |
| 235 | next, _, err := rewriteDeepSeekCatalogUpgrade(raw) |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | var c Config |
| 240 | if _, err := toml.Decode(next, &c); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if c.Providers[0].Prices["deepseek-flash"].Input != 123.4 || !c.Providers[1].HasModel("deepseek-flash") || !strings.Contains(next, "# custom new-model rate") || !strings.Contains(next, "# keep selection comment") { |
| 244 | t.Fatalf("lost custom fields: %s", next) |
| 245 | } |
| 246 | } |
| 247 |