返回 DeepSeek-Reasonix
deepseek_protocol_migration_test.go
根目录 / internal / config / deepseek_protocol_migration_test.go
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
777 lines GO