| 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 |