| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/BurntSushi/toml" |
| 13 | |
| 14 | fileencoding "reasonix/internal/fileutil/encoding" |
| 15 | ) |
| 16 | |
| 17 | func TestMigrateLegacyDeepSeekProtocolUserConfigPreservesTOMLAndIsIdempotent(t *testing.T) { |
| 18 | home := t.TempDir() |
| 19 | t.Setenv("REASONIX_HOME", home) |
| 20 | path := filepath.Join(home, "config.toml") |
| 21 | raw := `# keep this user comment |
| 22 | config_version = 4 |
| 23 | default_model = "deepseek-flash/deepseek-v4-flash" |
| 24 | future_top_level = "preserve-me" |
| 25 | |
| 26 | [[providers]] |
| 27 | name = "deepseek-flash" |
| 28 | kind = "openai" # legacy wire |
| 29 | base_url = "https://api.deepseek.com" |
| 30 | model = "deepseek-v4-flash" |
| 31 | api_key_env = "DEEPSEEK_API_KEY" |
| 32 | balance_url = "https://api.deepseek.com/user/balance" |
| 33 | context_window = 1000000 |
| 34 | |
| 35 | [[providers]] |
| 36 | name = "deepseek-pro" |
| 37 | kind = "openai" |
| 38 | base_url = "https://api.deepseek.com/" |
| 39 | model = "deepseek-v4-pro" |
| 40 | api_key_env = "DEEPSEEK_API_KEY" |
| 41 | |
| 42 | [[providers]] |
| 43 | name = "other" |
| 44 | kind = "openai" |
| 45 | base_url = "https://gateway.example/v1" |
| 46 | model = "other-model" |
| 47 | api_key_env = "OTHER_KEY" |
| 48 | future_provider_field = "untouched" |
| 49 | ` |
| 50 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | |
| 54 | changed, err := MigrateLegacyDeepSeekProtocolUserConfig() |
| 55 | if err != nil { |
| 56 | t.Fatalf("MigrateLegacyDeepSeekProtocolUserConfig: %v", err) |
| 57 | } |
| 58 | if !changed { |
| 59 | t.Fatal("legacy official providers were not migrated") |
| 60 | } |
| 61 | updatedBytes, err := os.ReadFile(path) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | updated := string(updatedBytes) |
| 66 | if strings.Count(updated, `kind = "anthropic"`) != 2 || |
| 67 | strings.Count(updated, `base_url = "https://api.deepseek.com/anthropic"`) != 2 { |
| 68 | t.Fatalf("migrated provider protocol mismatch:\n%s", updated) |
| 69 | } |
| 70 | for _, preserved := range []string{ |
| 71 | "# keep this user comment", |
| 72 | `future_top_level = "preserve-me"`, |
| 73 | `future_provider_field = "untouched"`, |
| 74 | `base_url = "https://gateway.example/v1"`, |
| 75 | `kind = "anthropic" # legacy wire`, |
| 76 | } { |
| 77 | if !strings.Contains(updated, preserved) { |
| 78 | t.Errorf("migration dropped %q:\n%s", preserved, updated) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | cfg, err := LoadForEditReadOnlyStrict(path) |
| 83 | if err != nil { |
| 84 | t.Fatalf("load migrated config: %v", err) |
| 85 | } |
| 86 | for _, name := range []string{"deepseek-flash", "deepseek-pro"} { |
| 87 | entry, ok := cfg.Provider(name) |
| 88 | if !ok { |
| 89 | t.Fatalf("migrated provider %q missing", name) |
| 90 | } |
| 91 | model := strings.TrimSpace(entry.Default) |
| 92 | if model == "" { |
| 93 | model = strings.TrimSpace(entry.Model) |
| 94 | } |
| 95 | resolved, ok := cfg.ResolveModel(name + "/" + model) |
| 96 | if !ok { |
| 97 | t.Fatalf("migrated provider %q model %q did not resolve", name, model) |
| 98 | } |
| 99 | cap := EffortCapabilityForEntry(resolved) |
| 100 | if entry.Kind != "anthropic" || entry.BaseURL != deepSeekAnthropicBaseURL || |
| 101 | entry.Thinking != "enabled" || !EffectiveWebSearch(entry) || |
| 102 | cap.Default != "high" || len(cap.Levels) == 0 { |
| 103 | t.Errorf("migrated provider %q capabilities = %+v effort=%+v", name, entry, cap) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | beforeSecondRun := string(updatedBytes) |
| 108 | changed, err = MigrateLegacyDeepSeekProtocolUserConfig() |
| 109 | if err != nil { |
| 110 | t.Fatalf("second migration: %v", err) |
| 111 | } |
| 112 | if changed { |
| 113 | t.Fatal("second migration unexpectedly reported a change") |
| 114 | } |
| 115 | afterSecondRun, err := os.ReadFile(path) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if string(afterSecondRun) != beforeSecondRun { |
| 120 | t.Fatal("idempotent migration rewrote the config on its second run") |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestAutomaticDeepSeekProtocolMigrationReportsMalformedConfigWithoutRewriting(t *testing.T) { |
| 125 | home := t.TempDir() |
| 126 | t.Setenv("REASONIX_HOME", home) |
| 127 | path := filepath.Join(home, "config.toml") |
| 128 | raw := `[[providers]] |
| 129 | name = "deepseek-flash" |
| 130 | kind = "openai" |
| 131 | base_url = "https://api.deepseek.com" |
| 132 | model = "deepseek-v4-flash" |
| 133 | api_key_env = "DEEPSEEK_API_KEY" |
| 134 | |
| 135 | [[plugins]] |
| 136 | name = "windows-mcp" |
| 137 | command = "C:\Users\reasonix\mcp.exe" |
| 138 | ` |
| 139 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | |
| 143 | changed, err := MigrateLegacyDeepSeekProtocolUserConfig() |
| 144 | if err == nil { |
| 145 | t.Fatal("automatic migration accepted malformed config") |
| 146 | } |
| 147 | if !IsDeepSeekProtocolConfigParseError(err) { |
| 148 | t.Fatalf("automatic migration error type = %T, want TOML parse error", err) |
| 149 | } |
| 150 | if changed { |
| 151 | t.Fatal("automatic migration reported changing malformed config") |
| 152 | } |
| 153 | next, err := os.ReadFile(path) |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if string(next) != raw { |
| 158 | t.Fatalf("automatic migration rewrote malformed config:\n%s", next) |
| 159 | } |
| 160 | |
| 161 | cfg, err := LoadForRootReadOnly(t.TempDir()) |
| 162 | if err != nil { |
| 163 | t.Fatalf("resilient config load: %v", err) |
| 164 | } |
| 165 | if !cfg.HasLoadWarnings() { |
| 166 | t.Fatal("resilient config loader did not expose the malformed config") |
| 167 | } |
| 168 | |
| 169 | if _, err := UpgradeDeepSeekProviderProtocol(path, "deepseek"); err == nil { |
| 170 | t.Fatal("explicit upgrade accepted malformed config") |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestUpgradeDeepSeekProviderProtocolWritesThroughSymlinkAndPreservesMode(t *testing.T) { |
| 175 | dir := t.TempDir() |
| 176 | target := filepath.Join(dir, "shared-config.toml") |
| 177 | link := filepath.Join(dir, "config.toml") |
| 178 | raw := `[[providers]] |
| 179 | name = "deepseek-flash" |
| 180 | kind = "openai" |
| 181 | base_url = "https://api.deepseek.com" |
| 182 | model = "deepseek-v4-flash" |
| 183 | api_key_env = "DEEPSEEK_API_KEY" |
| 184 | ` |
| 185 | if err := os.WriteFile(target, []byte(raw), 0o640); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | if err := os.Symlink(target, link); err != nil { |
| 189 | t.Skipf("symlinks are unavailable: %v", err) |
| 190 | } |
| 191 | |
| 192 | changed, err := UpgradeDeepSeekProviderProtocol(link, "deepseek") |
| 193 | if err != nil { |
| 194 | t.Fatalf("UpgradeDeepSeekProviderProtocol: %v", err) |
| 195 | } |
| 196 | if !changed { |
| 197 | t.Fatal("symlinked DeepSeek provider was not upgraded") |
| 198 | } |
| 199 | if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 { |
| 200 | t.Fatalf("logical config link was replaced: info=%v err=%v", info, err) |
| 201 | } |
| 202 | if runtime.GOOS != "windows" { |
| 203 | info, err := os.Stat(target) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if got := info.Mode().Perm(); got != 0o640 { |
| 208 | t.Fatalf("migrated target mode = %04o, want 0640", got) |
| 209 | } |
| 210 | } |
| 211 | got, err := os.ReadFile(target) |
| 212 | if err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | if !strings.Contains(string(got), `kind = "anthropic"`) || |
| 216 | !strings.Contains(string(got), `base_url = "https://api.deepseek.com/anthropic"`) { |
| 217 | t.Fatalf("symlink target was not upgraded:\n%s", got) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestMigrateLegacyDeepSeekProtocolUserConfigSerializesConcurrentUpgrades(t *testing.T) { |
| 222 | home := t.TempDir() |
| 223 | t.Setenv("REASONIX_HOME", home) |
| 224 | path := filepath.Join(home, "config.toml") |
| 225 | raw := `[[providers]] |
| 226 | name = "deepseek-flash" |
| 227 | kind = "openai" |
| 228 | base_url = "https://api.deepseek.com" |
| 229 | model = "deepseek-v4-flash" |
| 230 | api_key_env = "DEEPSEEK_API_KEY" |
| 231 | ` |
| 232 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | |
| 236 | const workers = 12 |
| 237 | start := make(chan struct{}) |
| 238 | errs := make(chan error, workers) |
| 239 | var wg sync.WaitGroup |
| 240 | for range workers { |
| 241 | wg.Go(func() { |
| 242 | <-start |
| 243 | _, err := MigrateLegacyDeepSeekProtocolUserConfig() |
| 244 | errs <- err |
| 245 | }) |
| 246 | } |
| 247 | close(start) |
| 248 | wg.Wait() |
| 249 | close(errs) |
| 250 | for err := range errs { |
| 251 | if err != nil { |
| 252 | t.Fatalf("concurrent migration: %v", err) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | got, err := os.ReadFile(path) |
| 257 | if err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | if strings.Count(string(got), `kind = "anthropic"`) != 1 || |
| 261 | strings.Count(string(got), `base_url = "https://api.deepseek.com/anthropic"`) != 1 { |
| 262 | t.Fatalf("concurrent migration produced a corrupt or partial config:\n%s", got) |
| 263 | } |
| 264 | if _, err := LoadForEditReadOnlyStrict(path); err != nil { |
| 265 | t.Fatalf("concurrently migrated config is invalid: %v", err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | func TestDeepSeekProtocolUpgradeAvailabilityUsesUserConfigSource(t *testing.T) { |
| 270 | home := t.TempDir() |
| 271 | t.Setenv("REASONIX_HOME", home) |
| 272 | path := filepath.Join(home, "config.toml") |
| 273 | if err := os.WriteFile(path, []byte("# unrelated user settings\n"), 0o600); err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | if CanUpgradeDeepSeekProviderProtocolUserConfig("deepseek") { |
| 277 | t.Fatal("an unrelated user config must not expose an upgrade for a project-only provider") |
| 278 | } |
| 279 | |
| 280 | raw := `[[providers]] |
| 281 | name = "deepseek-flash" |
| 282 | kind = "openai" |
| 283 | base_url = "https://api.deepseek.com" |
| 284 | model = "deepseek-v4-flash" |
| 285 | api_key_env = "DEEPSEEK_API_KEY" |
| 286 | ` |
| 287 | if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | if !CanUpgradeDeepSeekProviderProtocolUserConfig("deepseek") { |
| 291 | t.Fatal("eligible user-global provider did not expose the grouped upgrade") |
| 292 | } |
| 293 | if CanUpgradeDeepSeekProviderProtocolUserConfig("unrelated") { |
| 294 | t.Fatal("an unrelated provider target unexpectedly exposed the DeepSeek upgrade") |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | func TestMigrateLegacyDeepSeekProtocolPreservesConfigEncoding(t *testing.T) { |
| 299 | home := t.TempDir() |
| 300 | t.Setenv("REASONIX_HOME", home) |
| 301 | path := filepath.Join(home, "config.toml") |
| 302 | raw := `# preserve UTF-16 configuration |
| 303 | [[providers]] |
| 304 | name = "deepseek-flash" |
| 305 | kind = "openai" |
| 306 | base_url = "https://api.deepseek.com" |
| 307 | model = "deepseek-v4-flash" |
| 308 | api_key_env = "DEEPSEEK_API_KEY" |
| 309 | ` |
| 310 | encoded := fileencoding.Encode(raw, fileencoding.UTF16LE) |
| 311 | if err := os.WriteFile(path, encoded, 0o600); err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | |
| 315 | changed, err := MigrateLegacyDeepSeekProtocolUserConfig() |
| 316 | if err != nil || !changed { |
| 317 | t.Fatalf("MigrateLegacyDeepSeekProtocolUserConfig: changed=%v err=%v", changed, err) |
| 318 | } |
| 319 | got, err := os.ReadFile(path) |
| 320 | if err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | if !bytes.HasPrefix(got, []byte{0xff, 0xfe}) { |
| 324 | t.Fatalf("migrated config lost its UTF-16LE BOM: %x", got[:min(len(got), 8)]) |
| 325 | } |
| 326 | decoded := string(fileencoding.DecodeToUTF8(got)) |
| 327 | if !strings.Contains(decoded, `kind = "anthropic"`) || |
| 328 | !strings.Contains(decoded, `base_url = "https://api.deepseek.com/anthropic"`) || |
| 329 | !strings.Contains(decoded, "# preserve UTF-16 configuration") { |
| 330 | t.Fatalf("migrated UTF-16 config = %q", decoded) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | func TestDeepSeekProtocolMigrationSupportsInlineProviderArrays(t *testing.T) { |
| 335 | raw := `config_version = 4 |
| 336 | future_prompt = """ |
| 337 | [not-a-section] |
| 338 | providers = [{ name = "quoted-example", kind = "openai" }] |
| 339 | """ |
| 340 | providers = [{ name = "deepseek-flash", kind = "openai", base_url = "https://api.deepseek.com", model = "deepseek-v4-flash", api_key_env = "DEEPSEEK_API_KEY" }, { name = "other", kind = "openai", base_url = "https://gateway.example/v1", model = "other-model", api_key_env = "OTHER_KEY", headers = { X-Trace = "keep,=#value" } }, { name = "local", kind = "ollama", model = "local-model" }] |
| 341 | ` |
| 342 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true) |
| 343 | if err != nil { |
| 344 | t.Fatalf("rewrite inline providers: %v", err) |
| 345 | } |
| 346 | if !changed { |
| 347 | t.Fatal("eligible inline DeepSeek provider was not migrated") |
| 348 | } |
| 349 | for _, want := range []string{ |
| 350 | `kind = "anthropic"`, |
| 351 | `base_url = "https://api.deepseek.com/anthropic"`, |
| 352 | `headers = { X-Trace = "keep,=#value" }`, |
| 353 | `base_url = "https://gateway.example/v1"`, |
| 354 | `{ name = "local", kind = "ollama", model = "local-model" }`, |
| 355 | `providers = [{ name = "quoted-example", kind = "openai" }]`, |
| 356 | } { |
| 357 | if !strings.Contains(next, want) { |
| 358 | t.Errorf("inline migration dropped %q:\n%s", want, next) |
| 359 | } |
| 360 | } |
| 361 | var decoded Config |
| 362 | if _, err := toml.Decode(next, &decoded); err != nil { |
| 363 | t.Fatalf("migrated inline TOML is invalid: %v\n%s", err, next) |
| 364 | } |
| 365 | if len(decoded.Providers) != 3 || decoded.Providers[0].Kind != "anthropic" || decoded.Providers[1].Kind != "openai" { |
| 366 | t.Fatalf("migrated inline providers = %+v", decoded.Providers) |
| 367 | } |
| 368 | again, changed, err := rewriteLegacyDeepSeekProtocol(next, "", true) |
| 369 | if err != nil { |
| 370 | t.Fatalf("second inline migration: %v", err) |
| 371 | } |
| 372 | if changed || again != next { |
| 373 | t.Fatal("inline provider migration is not idempotent") |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | func TestManualDeepSeekProtocolUpgradeSupportsMultilineInlineArray(t *testing.T) { |
| 378 | raw := `providers = [ |
| 379 | { name = "deepseek-pro", kind = 'openai', base_url = 'https://api.deepseek.com/v1', model = "deepseek-v4-pro", api_key_env = "CUSTOM_KEY", headers = { X-Route = "keep,=#route" }, future_capability = true }, |
| 380 | ] |
| 381 | ` |
| 382 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "deepseek", false) |
| 383 | if err != nil { |
| 384 | t.Fatalf("manual inline upgrade: %v", err) |
| 385 | } |
| 386 | if !changed || !strings.Contains(next, `kind = "anthropic"`) || |
| 387 | !strings.Contains(next, `base_url = "https://api.deepseek.com/anthropic"`) { |
| 388 | t.Fatalf("manual inline upgrade mismatch:\n%s", next) |
| 389 | } |
| 390 | for _, want := range []string{ |
| 391 | `api_key_env = "CUSTOM_KEY"`, |
| 392 | `headers = { X-Route = "keep,=#route" }`, |
| 393 | `future_capability = true`, |
| 394 | } { |
| 395 | if !strings.Contains(next, want) { |
| 396 | t.Errorf("manual inline upgrade dropped %q:\n%s", want, next) |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | func TestAutomaticDeepSeekProtocolMigrationKeepsCustomizedProviders(t *testing.T) { |
| 402 | tests := []struct { |
| 403 | name string |
| 404 | extra string |
| 405 | }{ |
| 406 | {name: "proxy endpoint", extra: `base_url = "https://proxy.example/v1"`}, |
| 407 | {name: "custom headers", extra: `headers = { X-Route = "custom" }`}, |
| 408 | {name: "explicit model list", extra: `models = ["deepseek-v4-flash"]`}, |
| 409 | {name: "vision override", extra: `vision = true`}, |
| 410 | {name: "reasoning override", extra: `reasoning_protocol = "none"`}, |
| 411 | {name: "effort override", extra: `supported_efforts = ["high"]`}, |
| 412 | {name: "custom key", extra: `api_key_env = "MY_DEEPSEEK_KEY"`}, |
| 413 | {name: "unknown future field", extra: `future_capability = true`}, |
| 414 | } |
| 415 | for _, tt := range tests { |
| 416 | t.Run(tt.name, func(t *testing.T) { |
| 417 | baseURL := `base_url = "https://api.deepseek.com"` |
| 418 | apiKey := `api_key_env = "DEEPSEEK_API_KEY"` |
| 419 | model := `model = "deepseek-v4-flash"` |
| 420 | switch { |
| 421 | case strings.HasPrefix(tt.extra, "base_url"): |
| 422 | baseURL = tt.extra |
| 423 | case strings.HasPrefix(tt.extra, "api_key_env"): |
| 424 | apiKey = tt.extra |
| 425 | case strings.HasPrefix(tt.extra, "models"): |
| 426 | model = tt.extra |
| 427 | } |
| 428 | extra := tt.extra |
| 429 | if tt.extra == baseURL || tt.extra == apiKey || tt.extra == model { |
| 430 | extra = "" |
| 431 | } |
| 432 | raw := `[[providers]] |
| 433 | name = "deepseek-flash" |
| 434 | kind = "openai" |
| 435 | ` + baseURL + "\n" + model + "\n" + apiKey + "\n" + extra + "\n" |
| 436 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true) |
| 437 | if err != nil { |
| 438 | t.Fatalf("rewriteLegacyDeepSeekProtocol: %v", err) |
| 439 | } |
| 440 | if changed || next != raw { |
| 441 | t.Fatalf("customized provider was automatically migrated:\n%s", next) |
| 442 | } |
| 443 | }) |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | func TestManualDeepSeekProtocolUpgradePreservesCapabilitiesAndUnknownFields(t *testing.T) { |
| 448 | raw := `[[providers]] |
| 449 | name = "deepseek-flash" |
| 450 | kind = 'openai' |
| 451 | base_url = 'https://api.deepseek.com' |
| 452 | model = "deepseek-v4-flash" |
| 453 | api_key_env = "DEEPSEEK_API_KEY" |
| 454 | vision = true |
| 455 | future_capability = "keep" |
| 456 | |
| 457 | [[providers]] |
| 458 | name = "deepseek-pro" |
| 459 | kind = "openai" |
| 460 | base_url = "https://api.deepseek.com" |
| 461 | model = "deepseek-v4-pro" |
| 462 | api_key_env = "DEEPSEEK_API_KEY" |
| 463 | reasoning_protocol = "none" |
| 464 | ` |
| 465 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "deepseek", false) |
| 466 | if err != nil { |
| 467 | t.Fatalf("rewriteLegacyDeepSeekProtocol: %v", err) |
| 468 | } |
| 469 | if !changed || strings.Count(next, `kind = "anthropic"`) != 2 || |
| 470 | strings.Count(next, `base_url = "https://api.deepseek.com/anthropic"`) != 2 { |
| 471 | t.Fatalf("manual family upgrade mismatch:\n%s", next) |
| 472 | } |
| 473 | for _, preserved := range []string{ |
| 474 | `vision = true`, |
| 475 | `future_capability = "keep"`, |
| 476 | `reasoning_protocol = "none"`, |
| 477 | } { |
| 478 | if !strings.Contains(next, preserved) { |
| 479 | t.Errorf("manual upgrade dropped %q:\n%s", preserved, next) |
| 480 | } |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | func TestDeepSeekProtocolMigrationSupportsQuotedKeys(t *testing.T) { |
| 485 | raw := `[[providers]] |
| 486 | "name" = "deepseek-flash" |
| 487 | 'kind' = 'openai' |
| 488 | "base_url" = 'https://api.deepseek.com' |
| 489 | 'model' = "deepseek-v4-flash" |
| 490 | "api_key_env" = "DEEPSEEK_API_KEY" |
| 491 | ` |
| 492 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true) |
| 493 | if err != nil { |
| 494 | t.Fatalf("rewrite quoted-key provider: %v", err) |
| 495 | } |
| 496 | if !changed { |
| 497 | t.Fatal("quoted-key provider was not migrated") |
| 498 | } |
| 499 | for _, want := range []string{ |
| 500 | `'kind' = "anthropic"`, |
| 501 | `"base_url" = "https://api.deepseek.com/anthropic"`, |
| 502 | } { |
| 503 | if !strings.Contains(next, want) { |
| 504 | t.Errorf("migration changed or dropped %q:\n%s", want, next) |
| 505 | } |
| 506 | } |
| 507 | var decoded Config |
| 508 | if _, err := toml.Decode(next, &decoded); err != nil { |
| 509 | t.Fatalf("migrated quoted-key TOML is invalid: %v\n%s", err, next) |
| 510 | } |
| 511 | if len(decoded.Providers) != 1 || decoded.Providers[0].Kind != "anthropic" || decoded.Providers[0].BaseURL != deepSeekAnthropicBaseURL { |
| 512 | t.Fatalf("migrated quoted-key provider = %+v", decoded.Providers) |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | func TestDeepSeekProtocolMigrationSupportsQuotedProviderTableHeaders(t *testing.T) { |
| 517 | raw := `[["providers"]] |
| 518 | name = "deepseek-flash" |
| 519 | kind = "openai" |
| 520 | base_url = "https://api.deepseek.com" |
| 521 | model = "deepseek-v4-flash" |
| 522 | api_key_env = "DEEPSEEK_API_KEY" |
| 523 | |
| 524 | [['providers']] |
| 525 | name = "deepseek-pro" |
| 526 | kind = "openai" |
| 527 | base_url = "https://api.deepseek.com" |
| 528 | model = "deepseek-v4-pro" |
| 529 | api_key_env = "DEEPSEEK_API_KEY" |
| 530 | ` |
| 531 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "", true) |
| 532 | if err != nil { |
| 533 | t.Fatalf("rewrite quoted provider table headers: %v", err) |
| 534 | } |
| 535 | if !changed || strings.Count(next, `kind = "anthropic"`) != 2 || |
| 536 | strings.Count(next, `base_url = "https://api.deepseek.com/anthropic"`) != 2 { |
| 537 | t.Fatalf("quoted provider table headers were not migrated:\n%s", next) |
| 538 | } |
| 539 | for _, header := range []string{`[["providers"]]`, `[['providers']]`} { |
| 540 | if !strings.Contains(next, header) { |
| 541 | t.Errorf("migration changed provider table header %q:\n%s", header, next) |
| 542 | } |
| 543 | } |
| 544 | var decoded Config |
| 545 | if _, err := toml.Decode(next, &decoded); err != nil { |
| 546 | t.Fatalf("migrated quoted-header TOML is invalid: %v\n%s", err, next) |
| 547 | } |
| 548 | if len(decoded.Providers) != 2 || decoded.Providers[0].Kind != "anthropic" || decoded.Providers[1].Kind != "anthropic" { |
| 549 | t.Fatalf("migrated quoted-header providers = %+v", decoded.Providers) |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | func TestManualDeepSeekProtocolUpgradeSkipsMultilineProviderText(t *testing.T) { |
| 554 | raw := `[[providers]] |
| 555 | name = "deepseek-flash" |
| 556 | kind = "openai" |
| 557 | base_url = "https://api.deepseek.com" |
| 558 | model = "deepseek-v4-flash" |
| 559 | api_key_env = "DEEPSEEK_API_KEY" |
| 560 | description = ''' |
| 561 | kind = "example" |
| 562 | base_url = "https://example.invalid" |
| 563 | ''' |
| 564 | ` |
| 565 | next, changed, err := rewriteLegacyDeepSeekProtocol(raw, "deepseek", false) |
| 566 | if err != nil { |
| 567 | t.Fatalf("manual multiline upgrade: %v", err) |
| 568 | } |
| 569 | if !changed || !strings.Contains(next, `kind = "anthropic"`) || !strings.Contains(next, `base_url = "https://api.deepseek.com/anthropic"`) { |
| 570 | t.Fatalf("manual multiline upgrade mismatch:\n%s", next) |
| 571 | } |
| 572 | for _, want := range []string{ |
| 573 | `kind = "example"`, |
| 574 | `base_url = "https://example.invalid"`, |
| 575 | } { |
| 576 | if !strings.Contains(next, want) { |
| 577 | t.Errorf("multiline provider text changed or dropped %q:\n%s", want, next) |
| 578 | } |
| 579 | } |
| 580 | var decoded Config |
| 581 | if _, err := toml.Decode(next, &decoded); err != nil { |
| 582 | t.Fatalf("migrated multiline TOML is invalid: %v\n%s", err, next) |
| 583 | } |
| 584 | if len(decoded.Providers) != 1 || decoded.Providers[0].Kind != "anthropic" { |
| 585 | t.Fatalf("migrated multiline provider = %+v", decoded.Providers) |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | func TestCanUpgradeDeepSeekProviderProtocolRejectsProxyButAllowsExplicitUpgradeOfCustomization(t *testing.T) { |
| 590 | base := ProviderEntry{ |
| 591 | Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", |
| 592 | Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", |
| 593 | } |
| 594 | if !CanUpgradeDeepSeekProviderProtocol(&base) { |
| 595 | t.Fatal("standard official provider should offer manual upgrade") |
| 596 | } |
| 597 | proxy := base |
| 598 | proxy.BaseURL = "https://deepseek.example/v1" |
| 599 | if CanUpgradeDeepSeekProviderProtocol(&proxy) { |
| 600 | t.Fatal("proxy endpoint should not offer manual upgrade") |
| 601 | } |
| 602 | headers := base |
| 603 | headers.Headers = map[string]string{"X-Route": "custom"} |
| 604 | if !CanUpgradeDeepSeekProviderProtocol(&headers) { |
| 605 | t.Fatal("custom headers should block automatic migration, not the explicit upgrade action") |
| 606 | } |
| 607 | versioned := base |
| 608 | versioned.BaseURL = "https://api.deepseek.com/v1" |
| 609 | if !CanUpgradeDeepSeekProviderProtocol(&versioned) { |
| 610 | t.Fatal("the official /v1 compatibility address should offer the explicit upgrade action") |
| 611 | } |
| 612 | customKey := base |
| 613 | customKey.APIKeyEnv = "MY_DEEPSEEK_KEY" |
| 614 | if !CanUpgradeDeepSeekProviderProtocol(&customKey) { |
| 615 | t.Fatal("an official provider with a custom key env should offer the explicit upgrade action") |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | func TestNormalizeOfficialDeepSeekModelsAddsProToResponses(t *testing.T) { |
| 620 | c := &Config{Providers: []ProviderEntry{{ |
| 621 | Name: "deepseek", Kind: "responses", BaseURL: "https://api.deepseek.com", |
| 622 | Model: "deepseek-v4-flash", |
| 623 | }}} |
| 624 | |
| 625 | normalizeOfficialDeepSeekModels(c) |
| 626 | p, ok := c.Provider("deepseek") |
| 627 | if !ok { |
| 628 | t.Fatal("DeepSeek provider missing after normalization") |
| 629 | } |
| 630 | if !p.HasModel("deepseek-v4-flash") || !p.HasModel("deepseek-v4-pro") { |
| 631 | t.Fatalf("Responses models = %v, want Flash and Pro", p.ModelList()) |
| 632 | } |
| 633 | } |
| 634 | |
| 635 | func TestNormalizeOfficialDeepSeekResponsesPresetAddsPro(t *testing.T) { |
| 636 | c := &Config{Providers: []ProviderEntry{{ |
| 637 | Name: "deepseek-responses", Kind: "responses", BaseURL: "https://api.deepseek.com", |
| 638 | Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", |
| 639 | }}} |
| 640 | |
| 641 | normalizeOfficialDeepSeekModels(c) |
| 642 | p, ok := c.Provider("deepseek-responses") |
| 643 | if !ok { |
| 644 | t.Fatal("deepseek-responses provider missing after normalization") |
| 645 | } |
| 646 | if !p.HasModel("deepseek-v4-flash") || !p.HasModel("deepseek-v4-pro") { |
| 647 | t.Fatalf("deepseek-responses models = %v, want Flash and Pro", p.ModelList()) |
| 648 | } |
| 649 | if p.Default != "deepseek-v4-flash" { |
| 650 | t.Fatalf("default = %q, want deepseek-v4-flash", p.Default) |
| 651 | } |
| 652 | flash, _ := c.ResolveModel("deepseek-responses/deepseek-v4-flash") |
| 653 | if !containsString(flash.SupportedEfforts, "low") { |
| 654 | t.Fatalf("Flash effort override = %+v", flash) |
| 655 | } |
| 656 | pro, _ := c.ResolveModel("deepseek-responses/deepseek-v4-pro") |
| 657 | if !containsString(pro.SupportedEfforts, "low") || !containsString(pro.SupportedEfforts, "max") { |
| 658 | t.Fatalf("Pro effort override = %+v", pro) |
| 659 | } |
| 660 | if len(p.ModelOverrides) != 0 { |
| 661 | t.Fatal("built-in reasoning defaults must not become saved overrides") |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | func TestNormalizeOfficialDeepSeekMultiModelPreservesProviderEfforts(t *testing.T) { |
| 666 | for _, tc := range []struct { |
| 667 | name, providerName, kind, baseURL string |
| 668 | }{ |
| 669 | {name: "responses", providerName: "deepseek-responses", kind: "responses", baseURL: "https://api.deepseek.com"}, |
| 670 | {name: "anthropic", providerName: "deepseek", kind: "anthropic", baseURL: deepSeekAnthropicBaseURL}, |
| 671 | } { |
| 672 | t.Run(tc.name, func(t *testing.T) { |
| 673 | c := &Config{Providers: []ProviderEntry{{ |
| 674 | Name: tc.providerName, Kind: tc.kind, BaseURL: tc.baseURL, |
| 675 | Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", |
| 676 | SupportedEfforts: []string{"disabled", "high"}, DefaultEffort: "high", |
| 677 | }}} |
| 678 | |
| 679 | normalizeOfficialDeepSeekModels(c) |
| 680 | for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} { |
| 681 | entry, ok := c.ResolveModel(tc.providerName + "/" + model) |
| 682 | if !ok { |
| 683 | t.Fatalf("%s did not resolve", model) |
| 684 | } |
| 685 | if !stringSlicesEqual(entry.SupportedEfforts, []string{"disabled", "high"}) { |
| 686 | t.Errorf("%s supported_efforts = %v, want provider-level custom vocabulary", model, entry.SupportedEfforts) |
| 687 | } |
| 688 | if _, err := NormalizeEffort(entry, "low"); err == nil { |
| 689 | t.Errorf("%s unexpectedly accepted low outside provider-level vocabulary", model) |
| 690 | } |
| 691 | } |
| 692 | }) |
| 693 | } |
| 694 | } |
| 695 | |
| 696 | func TestNormalizeOfficialDeepSeekProviderEffortsKeepsExplicitModelOverride(t *testing.T) { |
| 697 | c := &Config{Providers: []ProviderEntry{{ |
| 698 | Name: "deepseek-responses", Kind: "responses", BaseURL: "https://api.deepseek.com", |
| 699 | Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", |
| 700 | SupportedEfforts: []string{"disabled", "high"}, DefaultEffort: "high", |
| 701 | ModelOverrides: map[string]ProviderModelOverride{ |
| 702 | "deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "low", "high"}, DefaultEffort: "low"}, |
| 703 | }, |
| 704 | }}} |
| 705 | |
| 706 | normalizeOfficialDeepSeekModels(c) |
| 707 | pro, ok := c.ResolveModel("deepseek-responses/deepseek-v4-pro") |
| 708 | if !ok { |
| 709 | t.Fatal("Pro did not resolve") |
| 710 | } |
| 711 | if !stringSlicesEqual(pro.SupportedEfforts, []string{"disabled", "low", "high"}) || pro.DefaultEffort != "low" { |
| 712 | t.Fatalf("Pro override = %v/%q, want explicit per-model values", pro.SupportedEfforts, pro.DefaultEffort) |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | func TestNormalizeOfficialDeepSeekResponsesDoesNotRestoreUncheckedPro(t *testing.T) { |
| 717 | cases := []struct { |
| 718 | name string |
| 719 | overrides map[string]ProviderModelOverride |
| 720 | }{ |
| 721 | { |
| 722 | name: "settings uncheck keeps flash override", |
| 723 | overrides: map[string]ProviderModelOverride{ |
| 724 | "deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"}, |
| 725 | }, |
| 726 | }, |
| 727 | { |
| 728 | name: "leftover pro override is still treated as curated", |
| 729 | overrides: map[string]ProviderModelOverride{ |
| 730 | "deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"}, |
| 731 | }, |
| 732 | }, |
| 733 | } |
| 734 | for _, tc := range cases { |
| 735 | t.Run(tc.name, func(t *testing.T) { |
| 736 | c := &Config{Providers: []ProviderEntry{{ |
| 737 | Name: "deepseek-responses", Kind: "responses", BaseURL: "https://api.deepseek.com", |
| 738 | Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", |
| 739 | ModelOverrides: tc.overrides, |
| 740 | }}} |
| 741 | |
| 742 | normalizeOfficialDeepSeekModels(c) |
| 743 | p, ok := c.Provider("deepseek-responses") |
| 744 | if !ok { |
| 745 | t.Fatal("deepseek-responses provider missing after normalization") |
| 746 | } |
| 747 | if p.HasModel("deepseek-v4-pro") { |
| 748 | t.Fatalf("unchecked Pro was restored: %v", p.ModelList()) |
| 749 | } |
| 750 | }) |
| 751 | } |
| 752 | } |
| 753 | |
| 754 | func TestNormalizeOfficialDeepSeekResponsesAddsProPriceForLegacyFlashPrice(t *testing.T) { |
| 755 | flash := deepSeekV4FlashPriceUSD() |
| 756 | c := &Config{Providers: []ProviderEntry{{ |
| 757 | Name: "deepseek-responses", Kind: "responses", BaseURL: "https://api.deepseek.com", |
| 758 | Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", |
| 759 | Price: flash, |
| 760 | }}} |
| 761 | |
| 762 | normalizeOfficialDeepSeekModels(c) |
| 763 | p, ok := c.Provider("deepseek-responses") |
| 764 | if !ok { |
| 765 | t.Fatal("deepseek-responses provider missing after normalization") |
| 766 | } |
| 767 | if !p.HasModel("deepseek-v4-pro") { |
| 768 | t.Fatalf("Responses models = %v, want Flash and Pro", p.ModelList()) |
| 769 | } |
| 770 | if got := p.PriceForModel("deepseek-v4-flash"); !samePricing(got, flash) { |
| 771 | t.Fatalf("Flash price = %+v, want legacy singular price %+v", got, flash) |
| 772 | } |
| 773 | if got := p.PriceForModel("deepseek-v4-pro"); !samePricing(got, deepSeekV4ProPriceUSD()) { |
| 774 | t.Fatalf("Pro price = %+v, want official Pro list price", got) |
| 775 | } |
| 776 | } |
| 777 |