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