返回 DeepSeek-Reasonix
opencode_go_upgrade_test.go
根目录 / internal / config / opencode_go_upgrade_test.go
1 package config
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "testing"
14
15 "github.com/BurntSushi/toml"
16 "reasonix/internal/fileutil"
17 fileencoding "reasonix/internal/fileutil/encoding"
18 )
19
20 const openCodeGoUpgradeFixture = `# keep the user's introduction
21 config_version = 9 # version comment
22 default_model = "go/deepseek-v4-pro"
23 future_root = "untouched"
24 [agent]
25 planner_model = "go/deepseek-v4-flash"
26 vision_model = "go/deepseek-v4-flash-vision-exp"
27 guardian_model = "go/deepseek-v4-pro"
28 recovery_model = "go/deepseek-v4-pro"
29 subagent_model = "go/deepseek-v4-pro"
30 web_search_model = "go/deepseek-v4-flash"
31 [agent.subagent_models]
32 review = "go/deepseek-v4-pro"
33 [desktop]
34 provider_access = ["go"]
35 [[providers]]
36 name = "go"
37 kind = "anthropic" # API comment
38 base_url = "https://opencode.ai/zen/go"
39 request_url = "https://opencode.ai/zen/go/v1/messages"
40 api_key_env = "ACCOUNT_A_KEY"
41 models = [
42 "deepseek-v4-pro", # favorite
43 "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
44 "qwen3.8-max", "grok-4.5", "unknown-future-model"
45 ]
46 default = "deepseek-v4-pro"
47 thinking = "enabled"
48 effort = "max"
49 web_search = true
50 max_output_tokens = 777
51 future_provider = { strange = "kept" }
52 [providers.headers]
53 X-User = "my-header"
54 [providers.prices.deepseek-v4-pro]
55 input = 0.123
56 output = 0.456
57 [providers.model_overrides.deepseek-v4-pro]
58 context_window = 765432
59 max_output_tokens = 555
60 future_override = "keep too"
61 [[providers]]
62 name = "go-chat"
63 kind = "openai"
64 base_url = "https://opencode.ai/zen/go/v1"
65 api_key_env = "ACCOUNT_B_KEY"
66 model = "deepseek-v4-pro"
67 web_search = false
68 [bot]
69 model = "go/deepseek-v4-pro"
70 [[bot.connections]]
71 name = "a"
72 model = "go/deepseek-v4-pro"
73 [[bot.connections]]
74 name = "b"
75 model = "go/deepseek-v4-flash"
76 `
77
78 func TestOpenCodeGoV10MigrationPreservesAccountsHistorySearchAndRawFields(t *testing.T) {
79 t.Setenv("ACCOUNT_A_KEY", "test-account-a")
80 t.Setenv("ACCOUNT_B_KEY", "test-account-b")
81 path := filepath.Join(t.TempDir(), "config.toml")
82 if err := os.WriteFile(path, []byte(openCodeGoUpgradeFixture), 0600); err != nil {
83 t.Fatal(err)
84 }
85 if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || !changed {
86 t.Fatalf("upgrade=%v, %v", changed, err)
87 }
88 raw, _ := os.ReadFile(path)
89 for _, text := range []string{"# keep the user's introduction", "# version comment", "# API comment", "# favorite", `future_root = "untouched"`, `future_provider = { strange = "kept" }`, `future_override = "keep too"`} {
90 if !bytes.Contains(raw, []byte(text)) {
91 t.Errorf("lost %s", text)
92 }
93 }
94 var cfg Config
95 if _, err := toml.Decode(string(raw), &cfg); err != nil {
96 t.Fatal(err)
97 }
98 cfg.loadOpenCodeGoJournal(path)
99 for i := range cfg.Providers {
100 cfg.Providers[i].resolvedAPIKey = "test-resolved-key"
101 }
102 if cfg.ConfigVersion != 10 || len(cfg.Providers) != 5 {
103 t.Fatalf("version/providers=%d/%d", cfg.ConfigVersion, len(cfg.Providers))
104 }
105 if cfg.DefaultModel != "go-chat-2/deepseek-v4-pro" {
106 t.Fatal(cfg.DefaultModel)
107 }
108 for _, ref := range []string{"go/deepseek-v4-pro", "go", "deepseek-v4-pro"} {
109 target, err := cfg.ResolveHistoricalModel(ref)
110 if err != nil {
111 t.Fatal(err)
112 }
113 e, ok := cfg.ResolveModel(target)
114 if !ok || e.Name != "go-chat-2" || e.APIKeyEnv != "ACCOUNT_A_KEY" || e.Kind != "openai" || e.Effort != "max" {
115 t.Fatalf("alias %s: %+v, %v", ref, e, ok)
116 }
117 if e.ContextWindow != 765432 || e.MaxOutputTokens != 555 || e.Price.Input != 0.123 {
118 t.Fatalf("lost model settings: %+v", e)
119 }
120 if err := ReasoningCapabilityForEntry(e).Validate(e.Model, EffectiveEffort(e)); err != nil {
121 t.Fatal(err)
122 }
123 }
124 for _, ref := range []string{cfg.Agent.PlannerModel, cfg.Agent.VisionModel, cfg.Agent.GuardianModel, cfg.Agent.RecoveryModel, cfg.Agent.SubagentModel, cfg.Agent.SubagentModels["review"], cfg.Bot.Model, cfg.Bot.Connections[0].Model, cfg.Bot.Connections[1].Model} {
125 if !strings.HasPrefix(ref, "go-chat-2/") {
126 t.Errorf("unmigrated role %q", ref)
127 }
128 }
129 for _, ref := range []string{cfg.Agent.WebSearchModel, "go/deepseek-v4-flash"} {
130 e, err := cfg.ResolveWebSearchModel(ref)
131 if err != nil || e.Kind != "anthropic" || e.APIKeyEnv != "ACCOUNT_A_KEY" {
132 t.Fatalf("search %q: %+v, %v", ref, e, err)
133 }
134 }
135 chat, _ := cfg.ResolveModel(cfg.DefaultModel)
136 cfg.Agent.WebSearchModel = "auto"
137 if search := cfg.ResolveWebSearchProvider(chat); search == nil || search.APIKeyEnv != "ACCOUNT_A_KEY" || search.Kind != "anthropic" {
138 t.Fatalf("automatic search: %+v", search)
139 }
140 chat.WebSearch = boolPointer(false)
141 if result := cfg.ResolveWebSearch(chat); result.Status != "disabled" {
142 t.Fatalf("disabled search: %+v", result)
143 }
144 backup, _ := os.ReadFile(path + ".opencode-go-v10.backup")
145 if string(backup) != openCodeGoUpgradeFixture {
146 t.Fatal("backup differs")
147 }
148 for _, suffix := range []string{".opencode-go-v10.backup", ".opencode-go-v10.json"} {
149 info, err := os.Stat(path + suffix)
150 // Windows carries no POSIX permission bits, so only the Unix legs can
151 // prove the sidecar is private.
152 if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) {
153 t.Fatalf("private sidecar: %v %v", info, err)
154 }
155 }
156 if changed, err := ApplyUserConfigUpgradesOnStartup(path); err != nil || changed {
157 t.Fatalf("repeat: %v %v", changed, err)
158 }
159 second, _ := os.ReadFile(path)
160 if !bytes.Equal(raw, second) {
161 t.Fatal("second startup wrote configuration")
162 }
163 p, _ := cfg.Provider("go-chat-2")
164 p.APIKeyEnv = "ACCOUNT_B_KEY"
165 if _, _, ok := cfg.ResolveModelWithFallback("go/deepseek-v4-pro"); ok {
166 t.Fatal("changed account silently fell back")
167 }
168 if err := cfg.ModelReferenceError("go/deepseek-v4-pro"); err == nil || !strings.Contains(err.Error(), "MIGRATED_MODEL_UNAVAILABLE") {
169 t.Fatal(err)
170 }
171 }
172
173 func TestOpenCodeGoV10MigrationVersionsAndInline(t *testing.T) {
174 for _, version := range []int{7, 8, 9} {
175 for _, inline := range []bool{false, true} {
176 t.Run(fmt.Sprintf("v%d/inline=%v", version, inline), func(t *testing.T) {
177 body := fmt.Sprintf("config_version = %d\n", version)
178 if inline {
179 body += `providers = [{name="go", kind="anthropic", base_url="https://opencode.ai/zen/go", request_url="https://opencode.ai/zen/go/v1/messages", api_key_env="GO_KEY", model="deepseek-v4-flash", thinking="enabled", effort="max", future={keep="yes"}}] # keep inline` + "\n"
180 } else {
181 body += "[[providers]]\nname='go'\nkind='anthropic'\nbase_url='https://opencode.ai/zen/go'\nmodel='deepseek-v4-flash'\nthinking='enabled'\neffort='max'\n"
182 }
183 path := filepath.Join(t.TempDir(), "config.toml")
184 _ = os.WriteFile(path, []byte(body), 0600)
185 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
186 t.Fatal(err)
187 }
188 var c Config
189 if _, err := decodeTOMLFile(path, &c); err != nil {
190 t.Fatal(err)
191 }
192 if c.ConfigVersion != 10 || c.Providers[0].Kind != "openai" || c.Providers[0].Effort != "max" {
193 t.Fatalf("%+v", c.Providers)
194 }
195 if inline {
196 raw, _ := os.ReadFile(path)
197 if !bytes.Contains(raw, []byte(`# keep inline`)) || !bytes.Contains(raw, []byte(`future={keep="yes"}`)) {
198 t.Fatal(string(raw))
199 }
200 }
201 })
202 }
203 }
204 }
205
206 func TestOpenCodeGoV10PreparedJournalAndDowngrade(t *testing.T) {
207 path := filepath.Join(t.TempDir(), "config.toml")
208 _ = os.WriteFile(path, []byte(openCodeGoUpgradeFixture), 0600)
209 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
210 t.Fatal(err)
211 }
212 raw, _ := os.ReadFile(path)
213 j := readOpenCodeGoJournal(path, raw)
214 if j == nil {
215 t.Fatal("missing journal")
216 }
217 j.Committed = false
218 data, _ := json.Marshal(j)
219 _ = os.WriteFile(path+".opencode-go-v10.json", data, 0600)
220 if readOpenCodeGoJournal(path, []byte(openCodeGoUpgradeFixture)) != nil {
221 t.Fatal("prepared mapping activated before commit")
222 }
223 if readOpenCodeGoJournal(path, raw) == nil {
224 t.Fatal("committed config hash did not recover prepared journal")
225 }
226 j.Committed = true
227 data, _ = json.Marshal(j)
228 _ = os.WriteFile(path+".opencode-go-v10.json", data, 0600)
229 // Simulate an older renderer saving version 9 without new internal fields.
230 var c Config
231 _, _ = toml.Decode(string(raw), &c)
232 c.ConfigVersion = 9
233 if err := c.SaveToScope(path, RenderScopeFull); err != nil {
234 t.Fatal(err)
235 }
236 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
237 t.Fatal(err)
238 }
239 var restored Config
240 _, _ = decodeTOMLFile(path, &restored)
241 restored.loadOpenCodeGoJournal(path)
242 if len(restored.Providers) != len(c.Providers) {
243 t.Fatal("downgrade created duplicate search connections")
244 }
245 if e, ok := restored.ResolveModel("go/deepseek-v4-pro"); !ok || e.Name != "go-chat-2" {
246 t.Fatalf("lost historical alias: %+v", e)
247 }
248 }
249
250 func TestOpenCodeGoV10ConcurrentAndEncoding(t *testing.T) {
251 path := filepath.Join(t.TempDir(), "config.toml")
252 // UTF-16LE with a BOM is a supported user configuration encoding.
253 raw := []byte{0xff, 0xfe}
254 for _, b := range []byte(openCodeGoUpgradeFixture) {
255 raw = append(raw, b, 0)
256 }
257 _ = os.WriteFile(path, raw, 0600)
258 var wg sync.WaitGroup
259 for range 4 {
260 wg.Go(func() {
261 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
262 t.Error(err)
263 }
264 })
265 }
266 wg.Wait()
267 next, _ := os.ReadFile(path)
268 if !bytes.HasPrefix(next, []byte{0xff, 0xfe}) {
269 t.Fatal("lost UTF16 encoding")
270 }
271 _, decoded := fileencoding.Detect(next)
272 if len(decoded) == 0 {
273 t.Fatal("empty output")
274 }
275 var c Config
276 if _, err := decodeTOMLFile(path, &c); err != nil || len(c.Providers) != 5 {
277 t.Fatalf("%d providers: %v", len(c.Providers), err)
278 }
279 }
280
281 func TestOpenCodeGoV10PendingAcknowledgementSurvivesEditsAndSave(t *testing.T) {
282 path := filepath.Join(t.TempDir(), "config.toml")
283 if err := os.WriteFile(path, []byte(openCodeGoUpgradeFixture), 0600); err != nil {
284 t.Fatal(err)
285 }
286 journalWrites := 0
287 changed, err := upgradeOpenCodeGoFileWithWriterLocked(path, func(target string, data []byte, mode os.FileMode) error {
288 if strings.HasSuffix(target, ".opencode-go-v10.json") {
289 journalWrites++
290 if journalWrites == 2 {
291 return errors.New("interrupted acknowledgement")
292 }
293 }
294 return fileutil.AtomicWriteFile(target, data, mode)
295 })
296 if !changed || err != nil {
297 t.Fatalf("commit: %v %v", changed, err)
298 }
299 raw, _ := os.ReadFile(path)
300 raw = append(raw, []byte("\n# unrelated later edit\n")...)
301 if err := os.WriteFile(path, raw, 0600); err != nil {
302 t.Fatal(err)
303 }
304 if j := readOpenCodeGoJournal(path, raw); j == nil || j.Committed {
305 t.Fatal("prepared journal must recover using commit marker")
306 }
307 c := LoadForEdit(path)
308 if err := c.SaveToScope(path, RenderScopeFull); err != nil {
309 t.Fatal(err)
310 }
311 restored := LoadForEdit(path)
312 if e, ok := restored.ResolveModel("go/deepseek-v4-pro"); !ok || e.Name != "go-chat-2" {
313 t.Fatalf("lost alias after save: %+v", e)
314 }
315 raw, _ = os.ReadFile(path)
316 if j := readOpenCodeGoJournal(path, raw); j == nil || !j.Committed {
317 t.Fatal("save did not finalize durable acknowledgement")
318 }
319 }
320
321 func TestOpenCodeGoV10AutoSearchDoesNotChangeAccountAfterDeletion(t *testing.T) {
322 var c Config
323 if _, err := toml.Decode(openCodeGoUpgradeFixture, &c); err != nil {
324 t.Fatal(err)
325 }
326 j, _ := planOpenCodeGoUpgrade(&c)
327 c.openCodeGoJournal = &j
328 chat, ok := c.ResolveModel("go/deepseek-v4-flash")
329 if !ok {
330 t.Fatal("missing chat")
331 }
332 c.Agent.WebSearchModel = "auto"
333 for i := range c.Providers {
334 if strings.Contains(c.Providers[i].Name, "search") {
335 c.Providers[i].APIKeyEnv = "OTHER_ACCOUNT"
336 }
337 }
338 if got := c.ResolveWebSearch(chat); got.Status != "invalid" || !strings.Contains(got.Reason, "MIGRATED_MODEL_UNAVAILABLE") {
339 t.Fatalf("search silently reassigned: %+v", got)
340 }
341 }
342
343 func TestOpenCodeGoV10DowngradeRetryKeepsOriginalProviderAlias(t *testing.T) {
344 path := filepath.Join(t.TempDir(), "config.toml")
345 fixture := strings.ReplaceAll(openCodeGoUpgradeFixture, "qwen3.8-max", "minimax-m3")
346 if err := os.WriteFile(path, []byte(fixture), 0600); err != nil {
347 t.Fatal(err)
348 }
349 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
350 t.Fatal(err)
351 }
352 c := LoadForEdit(path)
353 c.ConfigVersion = 9
354 if err := c.SaveToScope(path, RenderScopeFull); err != nil {
355 t.Fatal(err)
356 }
357 resolved, _, _ := statConfigPath(path)
358 _, err := upgradeOpenCodeGoFileWithWriterLocked(path, func(target string, data []byte, mode os.FileMode) error {
359 if target == resolved {
360 return errors.New("interrupted second upgrade")
361 }
362 return fileutil.AtomicWriteFile(target, data, mode)
363 })
364 if err == nil {
365 t.Fatal("expected interrupted commit")
366 }
367 // The prepared second generation cannot erase the first committed aliases.
368 c = LoadForEdit(path)
369 if ref, err := c.ResolveHistoricalModel("go"); err != nil || ref != "go-chat-2/deepseek-v4-pro" {
370 t.Fatalf("interruption changed historical provider alias: %q, %v", ref, err)
371 }
372 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
373 t.Fatal(err)
374 }
375 c = LoadForEdit(path)
376 if ref, err := c.ResolveHistoricalModel("go"); err != nil || ref != "go-chat-2/deepseek-v4-pro" {
377 t.Fatalf("retry changed historical provider alias to the remaining Anthropic group: %q, %v", ref, err)
378 }
379 }
380
381 func TestOpenCodeGoV10InterruptedCommitRetriesWithoutActivatingAliases(t *testing.T) {
382 path := filepath.Join(t.TempDir(), "config.toml")
383 _ = os.WriteFile(path, []byte(openCodeGoUpgradeFixture), 0600)
384 interrupted := errors.New("injected atomic replacement failure")
385 resolved, _, _ := statConfigPath(path)
386 changed, err := upgradeOpenCodeGoFileWithWriterLocked(path, func(target string, data []byte, mode os.FileMode) error {
387 if target == resolved {
388 return interrupted
389 }
390 return fileutil.AtomicWriteFile(target, data, mode)
391 })
392 if changed || !errors.Is(err, interrupted) {
393 t.Fatalf("%v %v", changed, err)
394 }
395 raw, _ := os.ReadFile(path)
396 if string(raw) != openCodeGoUpgradeFixture {
397 t.Fatal("failed migration modified original")
398 }
399 if readOpenCodeGoJournal(path, raw) != nil {
400 t.Fatal("failed migration activated pending aliases")
401 }
402 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
403 t.Fatal(err)
404 }
405 var cfg Config
406 _, _ = decodeTOMLFile(path, &cfg)
407 if len(cfg.Providers) != 5 {
408 t.Fatal("retry created duplicate providers")
409 }
410 backup, _ := os.ReadFile(path + ".opencode-go-v10.backup")
411 // Restoring the original backup and upgrading again produces the same
412 // stable identities; the durable previous alias remains valid.
413 _ = os.WriteFile(path, backup, 0600)
414 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
415 t.Fatal(err)
416 }
417 var restored Config
418 _, _ = decodeTOMLFile(path, &restored)
419 if len(restored.Providers) != 5 {
420 t.Fatal("backup recovery duplicated providers")
421 }
422 }
423
424 func TestOpenCodeGoV10CustomSettingsAndManualAlternates(t *testing.T) {
425 for _, customize := range []string{
426 "request_url='https://opencode.ai/zen/go/v1/messages?custom=1'\n",
427 "request_url='https://relay.example/v1/messages'\n",
428 "extra_body={ thinking={type='custom'} }\n",
429 } {
430 path := filepath.Join(t.TempDir(), "config.toml")
431 body := "config_version=9\n[[providers]]\nname='custom'\nkind='anthropic'\nbase_url='https://opencode.ai/zen/go'\nmodel='deepseek-v4-pro'\neffort='custom-depth'\n" + customize
432 _ = os.WriteFile(path, []byte(body), 0600)
433 if _, err := ApplyUserConfigUpgradesOnStartup(path); err != nil {
434 t.Fatal(err)
435 }
436 var cfg Config
437 _, _ = decodeTOMLFile(path, &cfg)
438 if cfg.Providers[0].Kind != "anthropic" || cfg.Providers[0].Effort != "custom-depth" {
439 t.Fatal("custom routing or effort overwritten")
440 }
441 }
442 path := filepath.Join(t.TempDir(), "config.toml")
443 body := "config_version=10\n[[providers]]\nname='manual'\nkind='anthropic'\nbase_url='https://opencode.ai/zen/go'\nmodel='deepseek-v4-pro'\nthinking='enabled'\neffort='max'\n"
444 _ = os.WriteFile(path, []byte(body), 0600)
445 if changed, err := ApplyUserConfigUpgradesOnStartup(path); changed || err != nil {
446 t.Fatalf("manual alternate: %v %v", changed, err)
447 }
448 raw, _ := os.ReadFile(path)
449 if string(raw) != body {
450 t.Fatal("manual alternate rewritten")
451 }
452 }
453
453 lines GO