返回 DeepSeek-Reasonix
load.go
根目录 / internal / config / load.go
1 package config
2
3 import (
4 "fmt"
5 "log/slog"
6 "maps"
7 "net/url"
8 "os"
9 "path/filepath"
10 "reflect"
11 "slices"
12 "strings"
13
14 "github.com/BurntSushi/toml"
15
16 "reasonix/internal/fileutil"
17 fileencoding "reasonix/internal/fileutil/encoding"
18 "reasonix/internal/provider"
19 )
20
21 // Load builds the configuration: defaults, then user config, then project
22 // config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)
23 // the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values
24 // resolve from Reasonix's global .env, not from project .env files.
25 func Load() (*Config, error) {
26 return LoadForRoot(".")
27 }
28
29 // LoadForRoot builds the configuration with project files resolved from root
30 // instead of the current working directory. When root is "" or ".", it behaves
31 // like Load(). This is the workspace-aware entry point: desktop tabs use it so
32 // each project's reasonix.toml + .mcp.json are resolved independently without
33 // changing the process cwd, while provider keys stay rooted in Reasonix home.
34 //
35 // Note: LoadForRoot may rewrite legacy MCP `tier` lines on disk (see
36 // mergeRuntimeTOMLFileSnapshot). Callers that must not mutate config files should use
37 // LoadForRootReadOnly instead.
38 func LoadForRoot(root string) (*Config, error) {
39 return loadForRoot(root, loadForRootOptions{migrateOnDisk: true, loadCredentials: true})
40 }
41
42 // LoadForRootReadOnly is like LoadForRoot but never writes config files: it skips
43 // on-disk legacy MCP tier migration. Prefer this for diagnostics, doctor, and
44 // other read-only inspection paths.
45 func LoadForRootReadOnly(root string) (*Config, error) {
46 return loadForRoot(root, loadForRootOptions{loadCredentials: true})
47 }
48
49 // LoadForRootWithoutCredentialsReadOnly is the credential-free form of
50 // LoadForRootReadOnly. It still merges the effective user + project config and
51 // carries project .env values for workspace-scoped expansion, but it neither
52 // pins Reasonix credentials into the process environment nor resolves provider
53 // API keys. Settings probes use it when they need runtime network policy before
54 // resolving only the edited provider's credential explicitly.
55 func LoadForRootWithoutCredentialsReadOnly(root string) (*Config, error) {
56 return loadForRoot(root, loadForRootOptions{})
57 }
58
59 // LoadUserConfigReadOnly loads only the trusted user-global config. It never
60 // reads project reasonix.toml files and never performs on-disk migrations.
61 // Host-owned features that may execute a configured binary should use this
62 // instead of LoadForRoot so an untrusted checkout cannot choose the process.
63 func LoadUserConfigReadOnly() (*Config, error) {
64 cfg := Default()
65 if path := userConfigLoadPath(); path != "" {
66 meta, err := mergeFileSnapshot(cfg, path)
67 if err != nil {
68 return nil, err
69 }
70 if meta.IsDefined("agent", "system_prompt_file") {
71 cfg.systemPromptFileSource = promptFileSourceUser
72 }
73 }
74 normalizeConfigForEdit(cfg)
75 cfg.loadOpenCodeGoJournal(userConfigLoadPath())
76 return cfg, nil
77 }
78
79 type loadForRootOptions struct {
80 migrateOnDisk bool
81 loadCredentials bool
82 }
83
84 func loadForRoot(root string, opts loadForRootOptions) (*Config, error) {
85 root = resolveRoot(root)
86 expansionEnv := loadProjectDotEnvForExpansion(root)
87 if opts.loadCredentials {
88 loadCredentialStoreForRoot(root)
89 }
90 cfg := Default()
91 cfg.setExpansionEnv(expansionEnv)
92 cfg.CredentialsStore = credentialsStoreMode()
93
94 projectTOML := "reasonix.toml"
95 if root != "." {
96 projectTOML = filepath.Join(root, "reasonix.toml")
97 }
98 if primary := userConfigPath(); primary != "" {
99 if _, err := resolveConfigAccessPath(primary, true); err != nil {
100 return nil, err
101 }
102 }
103 if _, err := resolveConfigAccessPath(projectTOML, false); err != nil {
104 return nil, err
105 }
106
107 mergeTOML := mergeFileSnapshot
108 if opts.migrateOnDisk {
109 mergeTOML = mergeRuntimeTOMLFileSnapshot
110 }
111
112 var tomlSources []string
113 userDefaultModelExplicit := false
114 if uc := userConfigLoadPath(); uc != "" {
115 tomlSources = append(tomlSources, uc)
116 meta, err := mergeTOML(cfg, uc)
117 if err != nil {
118 // Never rewrite the broken original file. Prefer the last verified
119 // snapshot in memory, then built-in defaults, and keep loading so
120 // the rest of the app stays usable.
121 lkgCfg := Default()
122 lkgCfg.setExpansionEnv(expansionEnv)
123 lkgCfg.CredentialsStore = credentialsStoreMode()
124 if lkgErr := loadLastKnownGoodUserConfig(lkgCfg); lkgErr == nil {
125 *cfg = *lkgCfg
126 cfg.addLoadWarning(fmt.Sprintf(
127 "user config %s is invalid (%v); using last-known-good snapshot in memory without modifying the original file",
128 uc, err,
129 ))
130 } else {
131 cfg.addLoadWarning(fmt.Sprintf(
132 "user config %s is invalid (%v); using built-in defaults in memory without modifying the original file",
133 uc, err,
134 ))
135 }
136 } else {
137 userDefaultModelExplicit = meta.IsDefined("default_model")
138 if meta.IsDefined("agent", "system_prompt_file") {
139 cfg.systemPromptFileSource = promptFileSourceUser
140 }
141 }
142 }
143 // A last-known-good recovery is still trusted user configuration even though
144 // the broken source file cannot provide usable TOML metadata.
145 if cfg.systemPromptFileSource == promptFileSourceUnknown && cfg.Agent.SystemPromptFile != "" {
146 cfg.systemPromptFileSource = promptFileSourceUser
147 }
148 userDefaultModel := cfg.DefaultModel
149 globalCLI := cfg.CLI
150 globalSecrets := cfg.Secrets
151 globalRemote, globalServe := cfg.Remote.Clone(), cfg.Serve
152 held := holdUserScope(cfg)
153 globalTelemetry, globalLegacyAnchorSafetyGate, globalStatusline := cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate, cfg.Statusline
154
155 tomlSources = append(tomlSources, projectTOML)
156 projectMeta, err := mergeTOML(cfg, projectTOML)
157 if err != nil {
158 // Project config damage is isolated to this workspace: continue with
159 // user/global config so other tabs stay available.
160 cfg.addLoadWarning(fmt.Sprintf(
161 "project config %s is invalid (%v); ignored for this workspace",
162 projectTOML, err,
163 ))
164 // Drop the project path from later multi-file merges so a broken TOML
165 // cannot fail plugin/provider re-merges.
166 tomlSources = tomlSources[:len(tomlSources)-1]
167 } else if projectMeta.IsDefined("agent", "system_prompt_file") {
168 cfg.systemPromptFileSource = promptFileSourceProject
169 }
170 // The native CLI update channel controls the one user-installed binary.
171 // A repository-local reasonix.toml must never switch that global choice.
172 cfg.CLI = globalCLI
173 // Secret protection is a user-global security control: a cloned repo's
174 // reasonix.toml must not be able to flip on the workflow-breaking env/path
175 // protections.
176 cfg.Secrets = globalSecrets
177 // Remote SSH hosts and serve authentication are equally user-global: a repo
178 // must not inject hosts, jump chains, or port forwards, nor choose serve's
179 // launch token, auth mode, or trust in forwarded headers.
180 cfg.Remote, cfg.Serve = globalRemote, globalServe
181 // Telemetry is a user-global privacy choice and the statusline a command the
182 // TUI runs unprompted: project config sets neither, even with no global value.
183 cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate, cfg.Statusline = globalTelemetry, globalLegacyAnchorSafetyGate, globalStatusline
184 // TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds
185 // only the last file's. Re-merge by name across all sources (later wins) so a
186 // project reasonix.toml doesn't drop the global config's MCP servers.
187 // mergeTOMLPlugins only reads files; it does not run on-disk migrations.
188 plugins, err := mergeTOMLPlugins(tomlSources)
189 if err != nil {
190 cfg.addLoadWarning(fmt.Sprintf("plugin configuration could not be merged (%v); continuing without those entries", err))
191 } else {
192 cfg.Plugins = plugins
193 }
194 if providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {
195 cfg.addLoadWarning(fmt.Sprintf("provider configuration could not be merged (%v); keeping providers already loaded", err))
196 } else if ok {
197 cfg.Providers = providers
198 cfg.providerSources = providerSources
199 cfg.shadowedProjectProviders = shadowedProjectProviders
200 }
201 if access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {
202 cfg.addLoadWarning(fmt.Sprintf("provider access configuration could not be merged (%v)", err))
203 } else if ok {
204 cfg.Desktop.ProviderAccess = access
205 }
206 // Sandbox, permission, program and regional grants only narrow (see heldScope).
207 held.narrow(cfg, root)
208
209 // Claude Code's .mcp.json (project root) is read last and merged into
210 // [[plugins]], so a server configured for Claude works here unchanged.
211 // Project reasonix.toml wins on a name collision; project .mcp.json wins
212 // over a same-name user-global entry (see mergeMCPJSON).
213 mcpFile := mcpJSONFile
214 if root != "." {
215 mcpFile = filepath.Join(root, mcpJSONFile)
216 }
217 entries, err := loadMCPJSON(mcpFile)
218 if err != nil {
219 cfg.addLoadWarning(fmt.Sprintf("project .mcp.json is invalid (%v); MCP servers from that file are ignored", err))
220 } else {
221 cfg.mergeMCPJSON(entries)
222 }
223
224 // Lowest priority before the one-time v1.9.1 MCP migration: the v0.x
225 // ~/.reasonix/config.json's mcpServers. Once the migration marker exists, the
226 // current config is authoritative even when it is empty; reading the legacy
227 // source again would resurrect servers the user removed from current config.
228 if !mcpGlobalMigrationComplete() {
229 cfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))
230 }
231 _ = mergeInstalledPluginPackages(cfg, root)
232 if err := normalizeRuntimeConfigWithMigrationJournal(cfg); err != nil {
233 return nil, err
234 }
235 if userDefaultModelExplicit {
236 restoreUnresolvableProjectDefaultModel(cfg, userDefaultModel)
237 }
238 cfg.CredentialsStore = credentialsStoreMode()
239 cfg.setExpansionEnv(expansionEnv)
240 if opts.loadCredentials {
241 resolveProviderCredentialsForRoot(root, cfg)
242 }
243 return cfg, nil
244 }
245
246 // LoadBuiltinDefaultsForRoot returns a read-only built-in-only configuration
247 // without reading or migrating user/project TOML. Diagnostic and recovery tools
248 // use it when configuration is malformed; it does not put the process into any
249 // degraded product "mode". Provider credentials still resolve only from
250 // Reasonix's global credential store.
251 func LoadBuiltinDefaultsForRoot(root string) *Config {
252 cfg := Default()
253 cfg.Plugins = nil
254 cfg.Skills = SkillsConfig{}
255 cfg.Bot.Enabled = false
256 cfg.Bot.Connections = nil
257 cfg.Bot.Routes = nil
258 cfg.Statusline.Command = ""
259 cfg.LSP.Enabled = false
260 cfg.setExpansionEnv(nil)
261 cfg.CredentialsStore = credentialsStoreMode()
262 resolveProviderCredentialsForRoot(root, cfg)
263 return cfg
264 }
265
266 // LoadRecoveryDefaultsForRoot is retained as an alias of LoadBuiltinDefaultsForRoot
267 // for older recovery call sites.
268 func LoadRecoveryDefaultsForRoot(root string) *Config {
269 return LoadBuiltinDefaultsForRoot(root)
270 }
271
272 func (c *Config) setExpansionEnv(env map[string]string) {
273 if c == nil {
274 return
275 }
276 c.expansionEnv = cloneStringMap(env)
277 for i := range c.Plugins {
278 // A project .env belongs to the project: it expands only servers the
279 // project declares, whose decision covers the values it supplies.
280 c.Plugins[i].expansionEnv = nil
281 if RepositoryDeclared(c.Plugins[i]) {
282 c.Plugins[i].expansionEnv = c.expansionEnv
283 }
284 }
285 }
286
287 func cloneStringMap(in map[string]string) map[string]string {
288 if len(in) == 0 {
289 return nil
290 }
291 out := make(map[string]string, len(in))
292 maps.Copy(out, in)
293 return out
294 }
295
296 // restoreUnresolvableProjectDefaultModel falls back to the user/global
297 // default_model when a project reasonix.toml overrides it with a reference no
298 // configured provider serves (#4218). Pre-v1.11 persistence paths (e.g. the
299 // "always allow" writer) full-rendered ./reasonix.toml and pinned the built-in
300 // default_model ("deepseek-flash") into it; once the user's [[providers]]
301 // replaced the built-in presets, that stale name resolved to nothing and boot
302 // hard-failed in every launch from that folder. In-memory only — the project
303 // file is untouched, and a project override that does resolve still wins. The
304 // ignored value is kept so boot can surface a notice.
305 //
306 // Callers must only invoke this when the user config explicitly defines
307 // default_model: falling back to the built-in default would silently mask a
308 // broken ref when the project file is the user's only config, and that case
309 // must keep the actionable boot error (TestBuildUnknownModelErrorIsActionable).
310 func restoreUnresolvableProjectDefaultModel(c *Config, userDefault string) {
311 if c == nil {
312 return
313 }
314 if c.DefaultModel == userDefault {
315 return
316 }
317 if _, ok := c.ResolveModel(c.DefaultModel); ok {
318 return
319 }
320 if _, ok := c.ResolveModel(userDefault); !ok {
321 return
322 }
323 c.ignoredProjectDefaultModel = c.DefaultModel
324 c.DefaultModel = userDefault
325 }
326
327 // tomlFileDefinesKey reports whether the TOML file at path explicitly defines
328 // the given top-level key. Missing or unparseable files report false.
329 func tomlFileDefinesKey(path string, key ...string) bool {
330 var f Config
331 meta, err := decodeTOMLFile(path, &f)
332 if err != nil {
333 return false
334 }
335 return meta.IsDefined(key...)
336 }
337
338 // backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard
339 // wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro
340 // SKU, leaving users unable to switch to it. In-memory only — the user's file is
341 // untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to
342 // serve pro) so a custom flash-only deployment isn't given an entry that 404s.
343 func backfillDeepSeekPro(c *Config) {
344 const flashModel, proModel = "deepseek-v4-flash", "deepseek-v4-pro"
345 var flash *ProviderEntry
346 for i := range c.Providers {
347 p := &c.Providers[i]
348 if p.Name == "deepseek-pro" {
349 return
350 }
351 for _, m := range p.ModelList() {
352 switch m {
353 case proModel:
354 return // pro already reachable
355 case flashModel:
356 if strings.Contains(p.BaseURL, "api.deepseek.com") {
357 flash = p
358 }
359 }
360 }
361 }
362 if flash == nil {
363 return
364 }
365 // If the user has explicitly curated a model list for the flash provider
366 // (e.g. unchecked pro in Settings), respect that choice and do not backfill.
367 if len(flash.Models) > 0 {
368 return
369 }
370 for _, bp := range Default().Providers {
371 if bp.Name == "deepseek-pro" {
372 bp.APIKeyEnv = flash.APIKeyEnv
373 // Inherit the flash provider's frozen billing currency for list prices.
374 currency := flash.ProviderBillingCurrency()
375 if currency == "" {
376 currency = flash.persistedOfficialCurrency
377 }
378 if currency == "" {
379 currency = "USD"
380 }
381 bp.BillingCurrency = currency
382 bp.persistedOfficialCurrency = currency
383 bp.Price = deepSeekV4PriceForModel(currency, proModel)
384 c.Providers = append(c.Providers, bp)
385 return
386 }
387 }
388 }
389
390 func backfillDeepSeekOfficialPrices(c *Config) {
391 if c == nil {
392 return
393 }
394 for i := range c.Providers {
395 p := &c.Providers[i]
396 if officialProviderKind(p) != "deepseek" {
397 continue
398 }
399 backfillDeepSeekOfficialEndpointDefaults(p)
400 currency := p.ProviderBillingCurrency()
401 if currency == "" {
402 currency = p.persistedOfficialCurrency
403 }
404 if currency == "" {
405 currency = "USD"
406 }
407 defaults := DeepSeekV4PricesForCurrency(currency)
408 if p.Price != nil {
409 continue
410 }
411 if p.Prices == nil {
412 p.Prices = map[string]*provider.Pricing{}
413 }
414 for model, price := range defaults {
415 if p.HasModel(model) && p.Prices[model] == nil {
416 p.Prices[model] = clonePricing(price)
417 }
418 }
419 }
420 }
421
422 // backfillDeepSeekOfficialEndpointDefaults restores the two official-endpoint
423 // fields a config may legitimately omit. Both are safe to infer here precisely
424 // because the caller already matched api.deepseek.com: the wallet endpoint is
425 // the vendor's own, and 1M is that vendor's real window. Values the file
426 // declares are never overwritten.
427 //
428 // This is keyed on the endpoint rather than on list position, so it cannot leak
429 // onto a custom provider the way the previous positional decode overlay did
430 // (#7357, #7358).
431 func backfillDeepSeekOfficialEndpointDefaults(p *ProviderEntry) {
432 if p == nil {
433 return
434 }
435 if strings.TrimSpace(p.BalanceURL) == "" {
436 p.BalanceURL = "https://api.deepseek.com/user/balance"
437 }
438 backfillOfficialContextWindow(p, 1_000_000)
439 }
440
441 func officialProviderKind(p *ProviderEntry) string {
442 if p == nil {
443 return ""
444 }
445 u, err := url.Parse(strings.TrimSpace(p.BaseURL))
446 if err != nil {
447 return ""
448 }
449 if strings.EqualFold(u.Hostname(), "api.deepseek.com") {
450 return "deepseek"
451 }
452 return ""
453 }
454
455 func resolveRoot(root string) string {
456 if root == "" || root == "." {
457 return "."
458 }
459 return filepath.Clean(root)
460 }
461
462 // normalizeLegacyEffort migrates the retired DeepSeek effort="off" (the old
463 // /thinking off that disabled thinking) to the provider default, so a config
464 // written by an older version keeps loading instead of erroring on a value the
465 // provider no longer accepts.
466 func normalizeLegacyEffort(c *Config) {
467 for i := range c.Providers {
468 if strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), "off") {
469 c.Providers[i].Effort = ""
470 }
471 }
472 }
473
474 // mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).
475 func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {
476 var merged []PluginEntry
477 index := map[string]int{}
478 for _, path := range paths {
479 _, exists, err := statConfigPath(path)
480 if err != nil {
481 return nil, fmt.Errorf("config %s: %w", path, err)
482 }
483 if !exists {
484 continue
485 }
486 var f Config
487 if _, err := decodeTOMLFile(path, &f); err != nil {
488 return nil, fmt.Errorf("config %s: %w", path, err)
489 }
490 for _, p := range f.Plugins {
491 p, _ = NormalizePluginCommandLine(p)
492 if isUserConfigPath(path) {
493 p.Source = MCPSourceUserConfig
494 } else {
495 p.Source = MCPSourceProjectConfig
496 }
497 if i, ok := index[p.Name]; ok {
498 merged[i] = p
499 continue
500 }
501 index[p.Name] = len(merged)
502 merged = append(merged, p)
503 }
504 }
505 return merged, nil
506 }
507
508 // mergeTOMLProviders merges [[providers]] across TOML sources by provider name.
509 // User-global providers win over same-named project providers; project providers
510 // only fill names the global config does not define. Keep official legacy aliases
511 // distinct here: they can carry different default models and effort capabilities,
512 // and the later desktop normalization layer handles canonical Settings access.
513 func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {
514 var merged []ProviderEntry
515 var shadowedProject []ProviderEntry
516 index := map[string]int{}
517 sources := map[string]providerSourceScope{}
518 saw := false
519 for _, path := range paths {
520 _, exists, err := statConfigPath(path)
521 if err != nil {
522 return nil, nil, nil, false, fmt.Errorf("config %s: %w", path, err)
523 }
524 if !exists {
525 continue
526 }
527 var f Config
528 if _, err := decodeTOMLFile(path, &f); err != nil {
529 return nil, nil, nil, false, fmt.Errorf("config %s: %w", path, err)
530 }
531 markPersistedDeepSeekOfficialPricing(&f)
532 if len(f.Providers) == 0 {
533 continue
534 }
535 saw = true
536 source := providerSourceForPath(path)
537 for _, p := range f.Providers {
538 normalizeProviderEffortFields(&p)
539 key := providerMergeKey(p)
540 if i, ok := index[key]; ok {
541 if sources[key] == providerSourceProject && source == providerSourceUser {
542 shadowedProject = append(shadowedProject, merged[i])
543 merged[i] = p
544 sources[key] = source
545 } else if sources[key] == providerSourceUser && source == providerSourceProject {
546 shadowedProject = append(shadowedProject, p)
547 }
548 continue
549 } else {
550 index[key] = len(merged)
551 merged = append(merged, p)
552 sources[key] = source
553 }
554 }
555 }
556 return merged, sources, shadowedProject, saw, nil
557 }
558
559 func providerSourceForPath(path string) providerSourceScope {
560 if isUserConfigPath(path) {
561 return providerSourceUser
562 }
563 return providerSourceProject
564 }
565
566 func providerMergeKey(p ProviderEntry) string {
567 return strings.TrimSpace(p.Name)
568 }
569
570 // mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so
571 // project desktop settings do not hide account-level providers from the desktop
572 // model switcher.
573 func mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {
574 var merged []string
575 seen := map[string]bool{}
576 saw := false
577 userDeclared := false
578 for _, path := range paths {
579 _, exists, err := statConfigPath(path)
580 if err != nil {
581 return nil, false, fmt.Errorf("config %s: %w", path, err)
582 }
583 if !exists {
584 continue
585 }
586 var f Config
587 meta, err := decodeTOMLFile(path, &f)
588 if err != nil {
589 return nil, false, fmt.Errorf("config %s: %w", path, err)
590 }
591 if !meta.IsDefined("desktop", "provider_access") {
592 continue
593 }
594 if !saw {
595 // Preserve declaration state even when the list is explicitly empty.
596 // A nil slice means legacy/undeclared access; a non-nil empty slice
597 // means the user intentionally removed every desktop provider.
598 merged = []string{}
599 }
600 saw = true
601 if isUserConfigPath(path) {
602 userDeclared = true
603 }
604 for _, name := range f.Desktop.ProviderAccess {
605 name = strings.TrimSpace(name)
606 if name == "" || seen[name] {
607 continue
608 }
609 seen[name] = true
610 merged = append(merged, name)
611 }
612 }
613 // An undeclared user list means "allow all"; a union with a project-only
614 // list would silently narrow that to whatever the project happens to name.
615 if saw && !userDeclared {
616 return nil, false, nil
617 }
618 return merged, saw, nil
619 }
620
621 // ConfigFileDeclarations contains provider settings explicitly declared by one
622 // TOML file, without defaults or values inherited from another scope.
623 type ConfigFileDeclarations struct {
624 ProviderNames []string
625 DesktopProviderAccessDeclared bool
626 }
627
628 // InspectConfigFileDeclarations returns the provider-related fields explicitly
629 // present in one TOML file. It deliberately does not include built-in defaults
630 // or values inherited from another config scope.
631 func InspectConfigFileDeclarations(path string) (ConfigFileDeclarations, error) {
632 var declarations ConfigFileDeclarations
633 path = strings.TrimSpace(path)
634 if path == "" {
635 return declarations, nil
636 }
637 _, exists, err := statConfigPath(path)
638 if err != nil {
639 return declarations, err
640 }
641 if !exists {
642 return declarations, nil
643 }
644 var f Config
645 meta, err := decodeTOMLFile(path, &f)
646 if err != nil {
647 return declarations, fmt.Errorf("config %s: %w", path, err)
648 }
649 seen := make(map[string]bool, len(f.Providers))
650 for _, provider := range f.Providers {
651 name := strings.TrimSpace(provider.Name)
652 if name == "" || seen[name] {
653 continue
654 }
655 seen[name] = true
656 declarations.ProviderNames = append(declarations.ProviderNames, name)
657 }
658 declarations.DesktopProviderAccessDeclared = meta.IsDefined("desktop", "provider_access")
659 return declarations, nil
660 }
661
662 // DesktopProviderAccessDeclared reports whether path explicitly declares
663 // desktop.provider_access. It distinguishes omission from an intentional [].
664 func DesktopProviderAccessDeclared(path string) (bool, error) {
665 declarations, err := InspectConfigFileDeclarations(path)
666 return declarations.DesktopProviderAccessDeclared, err
667 }
668
669 // LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:
670 // the built-in defaults with the file at path (if present) decoded on top, so a
671 // reconfigure preserves the user's existing providers and agent settings instead
672 // of resetting to defaults. Reasonix's global .env is loaded so api_key_env
673 // resolution works while the wizard decides which keys are still missing.
674 func LoadForEdit(path string) *Config {
675 return loadForEdit(path, true, false)
676 }
677
678 // LoadForEditReadOnlyStrict is the error-returning commit-time variant. It must
679 // not fall back to defaults when another writer leaves malformed TOML, because
680 // saving that fallback would overwrite the user's recoverable file.
681 func LoadForEditReadOnlyStrict(path string) (*Config, error) {
682 return loadForEditStrict(path, true, false)
683 }
684
685 // LoadForEditWithoutCredentialsReadOnlyStrict is the credential-free strict
686 // edit loader. It never writes migrations and never substitutes defaults for a
687 // malformed file.
688 func LoadForEditWithoutCredentialsReadOnlyStrict(path string) (*Config, error) {
689 return loadForEditStrict(path, false, false)
690 }
691
692 // ValidateFile parses one TOML config in isolation without loading credentials,
693 // applying migrations, or writing the file. A missing file is valid.
694 func ValidateFile(path string) error {
695 path = strings.TrimSpace(path)
696 if path == "" {
697 return nil
698 }
699 _, exists, err := statConfigPath(path)
700 if err != nil {
701 return err
702 }
703 if !exists {
704 return nil
705 }
706 cfg := Default()
707 if _, err := decodeTOMLFile(path, cfg); err != nil {
708 return fmt.Errorf("config %s: %w", path, err)
709 }
710 return nil
711 }
712
713 // ValidateBytes parses one in-memory TOML config without loading credentials,
714 // applying migrations, or writing any state.
715 func ValidateBytes(data []byte) error {
716 cfg := Default()
717 if _, err := decodeTOMLBytes(data, cfg); err != nil {
718 return fmt.Errorf("config: %w", err)
719 }
720 return nil
721 }
722
723 func loadForEdit(path string, loadCredentials, persistMigrations bool) *Config {
724 cfg, err := loadForEditStrict(path, loadCredentials, persistMigrations)
725 if err == nil {
726 return cfg
727 }
728 slog.Warn("config: load for edit failed, using defaults", "path", path, "err", err)
729 if loadCredentials {
730 loadDotEnvForEditPath(path)
731 }
732 cfg = Default()
733 normalizeConfigForEdit(cfg)
734 cfg.editLoadErr = err
735 return cfg
736 }
737
738 func LoadForEditWithoutCredentials(path string) *Config {
739 return loadForEdit(path, false, false)
740 }
741
742 func loadForEditStrict(path string, loadCredentials, persistMigrations bool) (*Config, error) {
743 if loadCredentials {
744 loadDotEnvForEditPath(path)
745 }
746 cfg := Default()
747 meta, err := mergeFileSnapshot(cfg, path)
748 if err != nil {
749 return nil, err
750 }
751 markExplicitDefaultProjectSkillKeys(cfg, path, meta)
752 changed := normalizeConfigForEdit(cfg)
753 cfg.loadOpenCodeGoJournal(path)
754 if persistMigrations && changed && strings.TrimSpace(path) != "" {
755 if _, err := os.Stat(path); err == nil {
756 if err := cfg.SaveTo(path); err != nil {
757 return nil, err
758 }
759 }
760 }
761 return cfg, nil
762 }
763
764 // markExplicitDefaultProjectSkillKeys preserves project skill fields that are
765 // explicitly present in a file but equal the built-in default. Without this
766 // transient provenance, saving an unrelated project setting would mistake an
767 // intentional `false`/empty override for a stale delta and remove it.
768 func markExplicitDefaultProjectSkillKeys(c *Config, path string, meta toml.MetaData) {
769 if c == nil || isUserConfigPath(path) {
770 return
771 }
772 for _, key := range projectSkillKeys {
773 if !meta.IsDefined("skills", key) || !projectSkillKeyIsDefault(c, key) {
774 continue
775 }
776 if c.explicitProjectSkillKeys == nil {
777 c.explicitProjectSkillKeys = make(map[string]bool)
778 }
779 c.explicitProjectSkillKeys[key] = true
780 }
781 }
782
783 func normalizeConfigForEdit(cfg *Config) bool {
784 normalizePluginCommandLines(cfg)
785 normalizeLegacyEffort(cfg)
786 normalizeLegacyAgentStepLimits(cfg)
787 changed := normalizeRetiredAutoPlan(cfg)
788 changed = normalizeRetiredMultiThresholdCompaction(cfg) || changed
789 normalizeLegacyMCPTiers(cfg)
790 changed = normalizeLegacyStepFunBaseURLs(cfg) || changed
791 changed = normalizeLegacyLongCatContextWindows(cfg) || changed
792 changed = normalizeLegacyQwenContextWindows(cfg) || changed
793 changed = normalizeLegacyStepFunContextWindows(cfg) || changed
794 changed = normalizeLegacyKimiK3Catalog(cfg) || changed
795 changed = normalizeLegacyOpenCodeGoInstalls(cfg) || changed
796 changed = normalizeLegacyMimoCustomProviders(cfg) || changed
797 normalizeLegacyProviderModels(cfg)
798 normalizeDesktopOfficialProviderAccess(cfg)
799 normalizeOfficialDeepSeekModels(cfg)
800 migrateBillingDisplayCurrency(cfg)
801 freezeProviderBillingCurrencies(cfg)
802 applyDeepSeekOfficialDefaultPricing(cfg)
803 backfillDeepSeekOfficialPrices(cfg)
804 normalizeEffortConfig(cfg)
805 return changed
806 }
807
808 // normalizeRetiredMultiThresholdCompaction clears retired multi-threshold keys
809 // so they never reach the Agent. Disk migration removes them on ordinary start;
810 // loading still ignores them if migration could not rewrite the file.
811 func normalizeRetiredMultiThresholdCompaction(c *Config) bool {
812 if c == nil {
813 return false
814 }
815 changed := c.Agent.SoftCompactRatio != 0 ||
816 c.Agent.ToolResultSnipRatio != 0 ||
817 c.Agent.CompactForceRatio != 0 ||
818 c.Agent.ColdResumePrune != nil ||
819 strings.TrimSpace(c.Agent.ContextEditing) != ""
820 c.Agent.SoftCompactRatio = 0
821 c.Agent.ToolResultSnipRatio = 0
822 c.Agent.CompactForceRatio = 0
823 c.Agent.ColdResumePrune = nil
824 c.Agent.ContextEditing = ""
825 if c.Agent.CompactRatio <= 0 {
826 c.Agent.CompactRatio = Default().Agent.CompactRatio
827 changed = true
828 }
829 return changed
830 }
831
832 // normalizeRetiredAutoPlan keeps pre-v5 configs readable while enforcing the
833 // single explicit-plan experience. The deprecated fields remain in AgentConfig
834 // only so old TOML and older desktop payloads decode safely.
835 func normalizeRetiredAutoPlan(c *Config) bool {
836 if c == nil {
837 return false
838 }
839 changed := strings.TrimSpace(c.Agent.AutoPlan) != "" && !strings.EqualFold(strings.TrimSpace(c.Agent.AutoPlan), "off") ||
840 strings.TrimSpace(c.Agent.AutoPlanClassifier) != ""
841 c.Agent.AutoPlan = "off"
842 c.Agent.AutoPlanClassifier = ""
843 return changed
844 }
845
846 func loadDotEnvForEditPath(path string) {
847 path = strings.TrimSpace(path)
848 if path == "" || isUserConfigPath(path) {
849 loadDotEnv()
850 return
851 }
852 loadDotEnvForRoot(filepath.Dir(path))
853 }
854
855 // mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.
856 func mergeFile(cfg *Config, path string) error {
857 _, err := mergeFileSnapshot(cfg, path)
858 return err
859 }
860
861 // mergeFileSnapshot decodes one immutable read of a TOML file onto cfg and
862 // returns metadata from those exact bytes. Callers that derive source or
863 // precedence decisions from metadata must use this result instead of reading
864 // the path again: a config file may be atomically replaced between reads.
865 func mergeFileSnapshot(cfg *Config, path string) (toml.MetaData, error) {
866 return mergeFileSnapshotWithRead(cfg, path, fileencoding.ReadFileUTF8)
867 }
868
869 func mergeFileSnapshotWithRead(cfg *Config, path string, readFile func(string) ([]byte, error)) (toml.MetaData, error) {
870 resolved, exists, err := statConfigPath(path)
871 if err != nil {
872 return toml.MetaData{}, err
873 }
874 if !exists {
875 return toml.MetaData{}, nil
876 }
877 data, err := readFile(resolved)
878 if err != nil {
879 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
880 }
881 // BurntSushi/toml decodes struct fields incrementally and can leave earlier
882 // fields mutated when a later value has the wrong type. Validate the complete
883 // snapshot against a disposable Config before merging those same bytes into
884 // the active object. This makes user LKG fallback, project-level isolation,
885 // and metadata-derived provenance transactional with respect to file changes.
886 var validated Config
887 if _, err := decodeTOMLBytes(data, &validated); err != nil {
888 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
889 }
890 meta, err := decodeTOMLBytes(data, cfg)
891 if err != nil {
892 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
893 }
894 if meta.IsDefined("providers") {
895 var persisted Config
896 if _, err := decodeTOMLBytes(data, &persisted); err != nil {
897 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
898 }
899 markPersistedDeepSeekOfficialPricing(&persisted)
900 markers := map[string]string{}
901 for i := range persisted.Providers {
902 markers[providerMergeKey(persisted.Providers[i])] = persisted.Providers[i].persistedOfficialCurrency
903 }
904 for i := range cfg.Providers {
905 cfg.Providers[i].persistedOfficialCurrency = markers[providerMergeKey(cfg.Providers[i])]
906 }
907 }
908 return meta, nil
909 }
910
911 func mergeRuntimeTOMLFileSnapshot(cfg *Config, path string) (toml.MetaData, error) {
912 if _, err := os.Stat(path); err == nil {
913 if err := migrateLegacyMCPTiersFile(path); err != nil {
914 slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
915 }
916 }
917 return mergeFileSnapshot(cfg, path)
918 }
919
920 // normalizeLegacyMCPTiers keeps loaded legacy config files on the new product
921 // behavior: enabled MCP servers connect in the background by default, and the
922 // retired per-server startup tier is no longer a user-facing setting.
923 func normalizeLegacyMCPTiers(c *Config) {
924 if c == nil {
925 return
926 }
927 for i := range c.Plugins {
928 c.Plugins[i].Tier = ""
929 }
930 }
931
932 // normalizeLegacyAgentStepLimits keeps old TOML readable without allowing a
933 // stale hidden value to override the adaptive progress policy. The fields stay
934 // in AgentConfig for decoder and cross-version desktop compatibility only.
935 func normalizeLegacyAgentStepLimits(c *Config) bool {
936 if c == nil {
937 return false
938 }
939 found := c.Agent.MaxSteps != 0 || c.Agent.PlannerMaxSteps != 0
940 c.Agent.MaxSteps = 0
941 c.Agent.PlannerMaxSteps = 0
942 return found
943 }
944
945 // MigrateLegacyAgentStepLimitsForRoot removes retired [agent] step-limit keys
946 // from the user and project config selected for root. Boot calls it immediately
947 // before LoadForRoot, so config-only/read-only commands never rewrite files and
948 // the runtime can surface exactly one migration notice.
949 func MigrateLegacyAgentStepLimitsForRoot(root string) (bool, error) {
950 root = resolveRoot(root)
951 paths := make([]string, 0, 2)
952 if userPath := userConfigLoadPath(); userPath != "" {
953 paths = append(paths, userPath)
954 }
955 projectPath := "reasonix.toml"
956 if root != "." {
957 projectPath = filepath.Join(root, "reasonix.toml")
958 }
959 paths = append(paths, projectPath)
960
961 changedAny := false
962 seen := make(map[string]struct{}, len(paths))
963 for _, path := range paths {
964 clean := filepath.Clean(path)
965 if _, ok := seen[clean]; ok {
966 continue
967 }
968 seen[clean] = struct{}{}
969 changed, err := migrateLegacyAgentStepLimitsFile(path)
970 if err != nil {
971 return changedAny, fmt.Errorf("migrate deprecated agent step limits in %s: %w", path, err)
972 }
973 changedAny = changedAny || changed
974 }
975 return changedAny, nil
976 }
977
978 // migrateLegacyAgentStepLimitsFile removes retired [agent] step-limit keys
979 // before runtime decoding. A process-wide lock makes concurrent desktop tab
980 // builds observe a single migration; the atomic rewrite protects other readers.
981 func migrateLegacyAgentStepLimitsFile(path string) (bool, error) {
982 return migrateRetiredConfigKeysFile(path, stripLegacyAgentStepLimitLines)
983 }
984
985 func stripLegacyAgentStepLimitLines(raw string) (string, bool) {
986 return stripTOMLKeyLines(raw, "agent", "max_steps", "planner_max_steps")
987 }
988
989 // MigrateLegacyRedactToolOutputForRoot removes the retired
990 // [secrets].redact_tool_output setting from the user and project configs chosen
991 // for root. The setting no longer controls any runtime behavior; removing it
992 // avoids leaving an explicit `true` value on disk that falsely suggests live
993 // output or transcript redaction is still active.
994 func MigrateLegacyRedactToolOutputForRoot(root string) (bool, error) {
995 root = resolveRoot(root)
996 paths := make([]string, 0, 2)
997 if userPath := userConfigLoadPath(); userPath != "" {
998 paths = append(paths, userPath)
999 }
1000 projectPath := "reasonix.toml"
1001 if root != "." {
1002 projectPath = filepath.Join(root, "reasonix.toml")
1003 }
1004 paths = append(paths, projectPath)
1005
1006 changedAny := false
1007 seen := make(map[string]struct{}, len(paths))
1008 for _, path := range paths {
1009 clean := filepath.Clean(path)
1010 if _, ok := seen[clean]; ok {
1011 continue
1012 }
1013 seen[clean] = struct{}{}
1014 changed, err := migrateLegacyRedactToolOutputFile(path)
1015 if err != nil {
1016 return changedAny, fmt.Errorf("migrate deprecated redact_tool_output in %s: %w", path, err)
1017 }
1018 changedAny = changedAny || changed
1019 }
1020 return changedAny, nil
1021 }
1022
1023 func migrateLegacyRedactToolOutputFile(path string) (bool, error) {
1024 return migrateRetiredConfigKeysFile(path, stripLegacyRedactToolOutputLines)
1025 }
1026
1027 func stripLegacyRedactToolOutputLines(raw string) (string, bool) {
1028 return stripTOMLKeyLines(raw, "secrets", "redact_tool_output")
1029 }
1030
1031 // MigrateLegacyMemoryCompilerForRoot removes the retired
1032 // [agent].memory_compiler setting from the user and project configs chosen for
1033 // root. The Memory v5 execution compiler was removed; stripping the key avoids
1034 // leaving values on disk that falsely suggest compiler behavior (especially a
1035 // stale verbosity = "compact") is still active.
1036 func MigrateLegacyMemoryCompilerForRoot(root string) (bool, error) {
1037 root = resolveRoot(root)
1038 paths := make([]string, 0, 2)
1039 if userPath := userConfigLoadPath(); userPath != "" {
1040 paths = append(paths, userPath)
1041 }
1042 projectPath := "reasonix.toml"
1043 if root != "." {
1044 projectPath = filepath.Join(root, "reasonix.toml")
1045 }
1046 paths = append(paths, projectPath)
1047
1048 changedAny := false
1049 seen := make(map[string]struct{}, len(paths))
1050 for _, path := range paths {
1051 clean := filepath.Clean(path)
1052 if _, ok := seen[clean]; ok {
1053 continue
1054 }
1055 seen[clean] = struct{}{}
1056 changed, err := migrateLegacyMemoryCompilerFile(path)
1057 if err != nil {
1058 return changedAny, fmt.Errorf("migrate deprecated memory_compiler in %s: %w", path, err)
1059 }
1060 changedAny = changedAny || changed
1061 }
1062 return changedAny, nil
1063 }
1064
1065 func migrateLegacyMemoryCompilerFile(path string) (bool, error) {
1066 return migrateRetiredConfigKeysFile(path, stripLegacyMemoryCompilerLines)
1067 }
1068
1069 func migrateRetiredConfigKeysFile(path string, strip func(string) (string, bool)) (bool, error) {
1070 unlock, err := LockConfigFileEdits(path)
1071 if err != nil {
1072 return false, err
1073 }
1074 defer unlock()
1075 resolved, exists, err := statConfigPath(path)
1076 if err != nil {
1077 return false, err
1078 }
1079 if !exists {
1080 return false, nil
1081 }
1082 info, err := os.Stat(resolved)
1083 if err != nil {
1084 return false, err
1085 }
1086 raw, err := fileencoding.ReadFileUTF8(resolved)
1087 if err != nil {
1088 return false, err
1089 }
1090 next, changed := strip(string(raw))
1091 if !changed {
1092 return false, nil
1093 }
1094 if err := fileutil.AtomicWriteFile(resolved, []byte(next), info.Mode().Perm()); err != nil {
1095 return false, err
1096 }
1097 return true, nil
1098 }
1099
1100 func stripLegacyMemoryCompilerLines(raw string) (string, bool) {
1101 return stripTOMLKeyLines(raw, "agent", "memory_compiler")
1102 }
1103
1104 // MigrateLegacyMultiThresholdCompactionForRoot strips retired soft/snip/force keys.
1105 func MigrateLegacyMultiThresholdCompactionForRoot(root string) (bool, error) {
1106 root = resolveRoot(root)
1107 paths := make([]string, 0, 2)
1108 if userPath := userConfigLoadPath(); userPath != "" {
1109 paths = append(paths, userPath)
1110 }
1111 projectPath := "reasonix.toml"
1112 if root != "." {
1113 projectPath = filepath.Join(root, "reasonix.toml")
1114 }
1115 paths = append(paths, projectPath)
1116
1117 changedAny := false
1118 seen := make(map[string]struct{}, len(paths))
1119 for _, path := range paths {
1120 clean := filepath.Clean(path)
1121 if _, ok := seen[clean]; ok {
1122 continue
1123 }
1124 seen[clean] = struct{}{}
1125 changed, err := migrateLegacyMultiThresholdCompactionFile(path)
1126 if err != nil {
1127 return changedAny, fmt.Errorf("migrate deprecated multi-threshold compaction keys in %s: %w", path, err)
1128 }
1129 changedAny = changedAny || changed
1130 }
1131 return changedAny, nil
1132 }
1133
1134 func migrateLegacyMultiThresholdCompactionFile(path string) (bool, error) {
1135 return migrateRetiredConfigKeysFile(path, stripLegacyMultiThresholdCompactionLines)
1136 }
1137
1138 func stripLegacyMultiThresholdCompactionLines(raw string) (string, bool) {
1139 return stripTOMLKeyLines(raw, "agent",
1140 "soft_compact_ratio",
1141 "tool_result_snip_ratio",
1142 "compact_force_ratio",
1143 "cold_resume_prune",
1144 "context_editing",
1145 )
1146 }
1147
1148 func migrateLegacyMCPTiersFile(path string) error {
1149 _, err := migrateRetiredConfigKeysFile(path, stripLegacyMCPTierLines)
1150 return err
1151 }
1152
1153 // MigrateLegacyMCPTiersForRoot keeps boot's historical on-disk migration
1154 // separate from immutable snapshots, whose freshness checks must be read-only.
1155 func MigrateLegacyMCPTiersForRoot(root string) {
1156 for _, path := range []string{userConfigLoadPath(), filepath.Join(resolveRoot(root), "reasonix.toml")} {
1157 if path == "" {
1158 continue
1159 }
1160 if err := migrateLegacyMCPTiersFile(path); err != nil {
1161 slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
1162 }
1163 }
1164 }
1165
1166 func stripLegacyMCPTierLines(raw string) (string, bool) {
1167 return stripTOMLKeyLines(raw, "plugins", "tier")
1168 }
1169
1170 // tomlStringState tracks whether a line-oriented scan is currently inside a
1171 // TOML multiline string, so retired-key strippers never treat prose inside a
1172 // `"""..."""` or `”'...”'` value (e.g. a config example quoted in a
1173 // system_prompt) as a section header or key assignment.
1174 type tomlStringState int
1175
1176 const (
1177 tomlOutside tomlStringState = iota
1178 tomlInMultilineBasic
1179 tomlInMultilineLiteral
1180 )
1181
1182 // advanceTOMLStringState scans one raw line and returns the multiline-string
1183 // state after it. Outside strings it honours single-line strings and `#`
1184 // comments so quote delimiters inside them cannot open a multiline state.
1185 // The scan is intentionally conservative: on malformed input it prefers
1186 // staying/returning outside, which makes callers keep lines rather than
1187 // delete them.
1188 func advanceTOMLStringState(state tomlStringState, line string) tomlStringState {
1189 i := 0
1190 for i < len(line) {
1191 switch state {
1192 case tomlInMultilineBasic:
1193 if line[i] == '\\' {
1194 i += 2
1195 continue
1196 }
1197 if strings.HasPrefix(line[i:], `"""`) {
1198 state = tomlOutside
1199 i += 3
1200 continue
1201 }
1202 i++
1203 case tomlInMultilineLiteral:
1204 if strings.HasPrefix(line[i:], "'''") {
1205 state = tomlOutside
1206 i += 3
1207 continue
1208 }
1209 i++
1210 default: // tomlOutside
1211 switch {
1212 case line[i] == '#':
1213 return state // rest of the line is a comment
1214 case strings.HasPrefix(line[i:], `"""`):
1215 state = tomlInMultilineBasic
1216 i += 3
1217 case strings.HasPrefix(line[i:], "'''"):
1218 state = tomlInMultilineLiteral
1219 i += 3
1220 case line[i] == '"': // single-line basic string
1221 i++
1222 for i < len(line) && line[i] != '"' {
1223 if line[i] == '\\' {
1224 i++
1225 }
1226 i++
1227 }
1228 i++ // closing quote (or line end on malformed input)
1229 case line[i] == '\'': // single-line literal string
1230 i++
1231 for i < len(line) && line[i] != '\'' {
1232 i++
1233 }
1234 i++
1235 default:
1236 i++
1237 }
1238 }
1239 }
1240 return state
1241 }
1242
1243 // stripTOMLKeyLines removes top-level `key = ...` assignment lines under the
1244 // named section while leaving every line inside a TOML multiline string
1245 // untouched. All retired-config-key migrations share it so none of them can
1246 // corrupt a multiline value (such as a system_prompt quoting a config
1247 // example). A dropped line is first checked to not itself open a multiline
1248 // value; if it would, the line is kept — for these retired keys that never
1249 // happens (their values are single-line), and keeping a stale line is always
1250 // safer than truncating a string the user wrote.
1251 func stripTOMLKeyLines(raw, section string, keys ...string) (string, bool) {
1252 lines := strings.Split(raw, "\n")
1253 current := ""
1254 state := tomlOutside
1255 changed := false
1256 out := make([]string, 0, len(lines))
1257 for _, line := range lines {
1258 if state != tomlOutside {
1259 // Inside a multiline string: never a section header or key line.
1260 out = append(out, line)
1261 state = advanceTOMLStringState(state, line)
1262 continue
1263 }
1264 if header := tomlSectionHeader(line); header != "" {
1265 current = header
1266 }
1267 next := advanceTOMLStringState(tomlOutside, line)
1268 if current == section && next == tomlOutside {
1269 dropped := false
1270 for _, key := range keys {
1271 if isTOMLKeyAssignment(line, key) {
1272 changed = true
1273 dropped = true
1274 break
1275 }
1276 }
1277 if dropped {
1278 continue
1279 }
1280 }
1281 out = append(out, line)
1282 state = next
1283 }
1284 return strings.Join(out, "\n"), changed
1285 }
1286
1287 func tomlSectionHeader(line string) string {
1288 trimmed := strings.TrimSpace(line)
1289 if !strings.HasPrefix(trimmed, "[") {
1290 return ""
1291 }
1292 if i := strings.Index(trimmed, "#"); i >= 0 {
1293 trimmed = strings.TrimSpace(trimmed[:i])
1294 }
1295 if strings.HasPrefix(trimmed, "[[") && strings.HasSuffix(trimmed, "]]") {
1296 return strings.TrimSpace(trimmed[2 : len(trimmed)-2])
1297 }
1298 if strings.HasSuffix(trimmed, "]") {
1299 return strings.TrimSpace(trimmed[1 : len(trimmed)-1])
1300 }
1301 return "other"
1302 }
1303
1304 // normalizeLegacyProviderModels repairs provider entries written by older
1305 // desktop builds that carried the official provider name/endpoint but omitted the
1306 // model field. The repair is intentionally narrow: valid user-provided model
1307 // lists are left untouched, while known official aliases get the model implied by
1308 // their preset name so model pickers and provider validation have an option.
1309 func normalizeLegacyProviderModels(c *Config) {
1310 if c == nil {
1311 return
1312 }
1313 for i := range c.Providers {
1314 p := &c.Providers[i]
1315 if providerHasAnyModel(*p) {
1316 continue
1317 }
1318 if model := legacyOfficialProviderModel(p.Name); model != "" {
1319 p.Model = model
1320 }
1321 }
1322 }
1323
1324 const (
1325 legacyStepFunOpenAIBaseURL = "https://api.stepfun.ai/step_plan/v1"
1326 officialStepFunOpenAIBaseURL = "https://api.stepfun.com/step_plan/v1"
1327 legacyStepFunAnthropicBaseURL = "https://api.stepfun.ai/step_plan"
1328 officialStepFunAnthropicBaseURL = "https://api.stepfun.com/step_plan"
1329 )
1330
1331 func normalizeLegacyStepFunBaseURLs(c *Config) bool {
1332 // Both stepfun.ai (global) and stepfun.com (China) are official endpoints.
1333 // BaseURL is user-owned provider configuration, so neither runtime loading
1334 // nor an unrelated settings save may infer a region and rewrite it.
1335 return false
1336 }
1337
1338 func normalizedBaseURLForMigration(raw string) string {
1339 return strings.TrimRight(strings.TrimSpace(raw), "/")
1340 }
1341
1342 func normalizeLegacyLongCatContextWindows(c *Config) bool {
1343 if c == nil {
1344 return false
1345 }
1346 changed := false
1347 for i := range c.Providers {
1348 p := &c.Providers[i]
1349 if p.ContextWindow != legacyLongCat20ContextWindow {
1350 continue
1351 }
1352 var kind, baseURL string
1353 switch strings.TrimSpace(p.PresetID) {
1354 case "longcat-openai":
1355 kind, baseURL = "openai", longCatOpenAIBaseURL
1356 case "longcat-anthropic":
1357 kind, baseURL = "anthropic", longCatAnthropicBaseURL
1358 default:
1359 continue
1360 }
1361 if !strings.EqualFold(strings.TrimSpace(p.Kind), kind) ||
1362 normalizedBaseURLForMigration(p.BaseURL) != baseURL ||
1363 !stringSlicesEqual(p.Models, longCat20Models) ||
1364 p.Model != "" ||
1365 p.Default != longCat20Models[0] {
1366 continue
1367 }
1368 p.ContextWindow = longCat20ContextWindow
1369 changed = true
1370 }
1371 return changed
1372 }
1373
1374 // normalizeLegacyQwenContextWindows upgrades only installed official Qwen
1375 // presets that still carry the old zero context window and untouched model
1376 // catalog. Custom endpoints, catalogs, provider-wide windows, and existing
1377 // per-model override values remain user-owned.
1378 func normalizeLegacyQwenContextWindows(c *Config) bool {
1379 if c == nil {
1380 return false
1381 }
1382 changed := false
1383 for i := range c.Providers {
1384 p := &c.Providers[i]
1385 if p.ContextWindow != 0 {
1386 continue
1387 }
1388 presetID := qwenPresetIDForMigration(*p)
1389 if presetID == "" {
1390 continue
1391 }
1392 preset, ok := CuratedProviderPreset(presetID)
1393 if !ok || len(preset.Entries) != 1 {
1394 continue
1395 }
1396 canonical := preset.Entries[0]
1397 if !strings.EqualFold(strings.TrimSpace(p.Kind), strings.TrimSpace(canonical.Kind)) ||
1398 normalizedBaseURLForMigration(p.BaseURL) != normalizedBaseURLForMigration(canonical.BaseURL) ||
1399 !stringSlicesEqual(p.Models, canonical.Models) ||
1400 strings.TrimSpace(p.Model) != "" {
1401 continue
1402 }
1403 p.ContextWindow = canonical.ContextWindow
1404 mergeMissingQwenContextOverrides(p, canonical.ModelOverrides)
1405 changed = true
1406 }
1407 return changed
1408 }
1409
1410 func qwenPresetIDForMigration(p ProviderEntry) string {
1411 presetID := strings.TrimSpace(p.PresetID)
1412 if presetID == "" {
1413 presetID = strings.TrimSpace(p.Name)
1414 }
1415 switch presetID {
1416 case "qwen-cn",
1417 "qwen-global",
1418 "qwen-coding-plan-cn",
1419 "qwen-coding-plan-cn-anthropic",
1420 "qwen-coding-plan-global",
1421 "qwen-coding-plan-global-anthropic":
1422 return presetID
1423 default:
1424 return ""
1425 }
1426 }
1427
1428 func mergeMissingQwenContextOverrides(p *ProviderEntry, defaults map[string]ProviderModelOverride) {
1429 if p == nil || len(defaults) == 0 {
1430 return
1431 }
1432 if p.ModelOverrides == nil {
1433 p.ModelOverrides = make(map[string]ProviderModelOverride, len(defaults))
1434 }
1435 for defaultKey, defaultOverride := range defaults {
1436 overrideKey := defaultKey
1437 for key := range p.ModelOverrides {
1438 if strings.EqualFold(strings.TrimSpace(key), defaultKey) {
1439 overrideKey = key
1440 break
1441 }
1442 }
1443 override := p.ModelOverrides[overrideKey]
1444 if override.ContextWindow == 0 {
1445 override.ContextWindow = defaultOverride.ContextWindow
1446 p.ModelOverrides[overrideKey] = override
1447 }
1448 }
1449 }
1450
1451 // normalizeLegacyKimiK3Catalog upgrades only untouched Kimi direct-API model
1452 // catalogs on the official regional endpoints. Custom model lists, endpoints,
1453 // defaults, credentials, and provider-wide settings remain user-owned.
1454 func normalizeLegacyKimiK3Catalog(c *Config) bool {
1455 if c == nil {
1456 return false
1457 }
1458 changed := false
1459 for i := range c.Providers {
1460 p := &c.Providers[i]
1461 presetID := strings.TrimSpace(p.PresetID)
1462 name := strings.TrimSpace(p.Name)
1463 var baseURL string
1464 switch {
1465 case presetID == "kimi-cn" || (presetID == "" && name == "kimi-cn"):
1466 baseURL = "https://api.moonshot.cn/v1"
1467 case presetID == "kimi-global" || (presetID == "" && name == "kimi-global"):
1468 baseURL = "https://api.moonshot.ai/v1"
1469 default:
1470 continue
1471 }
1472 if !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
1473 normalizedBaseURLForMigration(p.BaseURL) != baseURL ||
1474 !stringSlicesEqual(p.Models, legacyKimiAPIModels) ||
1475 strings.TrimSpace(p.Model) != "" {
1476 continue
1477 }
1478 p.Models = append([]string(nil), kimiAPIModels...)
1479 p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, legacyKimiAPIModels)
1480 mergeMissingKimiK3Override(p, kimiK3DirectOverride())
1481 changed = true
1482 }
1483 return changed
1484 }
1485
1486 // migrateKimiK3VisionModels preserves explicit provider-level vision choices.
1487 // A nil list or an exact copy of the old preset list indicates that the user
1488 // has not customized vision support and should receive Kimi K3's capability.
1489 func migrateKimiK3VisionModels(current, legacy []string) []string {
1490 if current != nil && (legacy == nil || !stringSlicesEqual(current, legacy)) {
1491 return current
1492 }
1493 return mergeModelLists([]string{"kimi-k3"}, current)
1494 }
1495
1496 func mergeMissingKimiK3Override(p *ProviderEntry, defaults ProviderModelOverride) {
1497 if p.ModelOverrides == nil {
1498 p.ModelOverrides = map[string]ProviderModelOverride{}
1499 }
1500 overrideKey := "kimi-k3"
1501 for key := range p.ModelOverrides {
1502 if strings.EqualFold(strings.TrimSpace(key), overrideKey) {
1503 overrideKey = key
1504 break
1505 }
1506 }
1507 kimiK3 := p.ModelOverrides[overrideKey]
1508 if strings.TrimSpace(kimiK3.ReasoningProtocol) == "" {
1509 kimiK3.ReasoningProtocol = defaults.ReasoningProtocol
1510 }
1511 if kimiK3.SupportedEfforts == nil {
1512 kimiK3.SupportedEfforts = append([]string(nil), defaults.SupportedEfforts...)
1513 }
1514 if strings.TrimSpace(kimiK3.DefaultEffort) == "" && containsString(normalizedEffortLevels(kimiK3.SupportedEfforts), defaults.DefaultEffort) {
1515 kimiK3.DefaultEffort = defaults.DefaultEffort
1516 }
1517 if kimiK3.ContextWindow <= 0 {
1518 kimiK3.ContextWindow = defaults.ContextWindow
1519 }
1520 p.ModelOverrides[overrideKey] = kimiK3
1521 }
1522
1523 // normalizeLegacyOpenCodeGoKimiK3Catalog upgrades only the untouched model
1524 // catalog from the original editable OpenCode Go preset. A user-curated model
1525 // list or custom endpoint is left alone, while other provider edits (headers,
1526 // key env, provider-wide context) survive the additive K3 capability update.
1527 func normalizeLegacyOpenCodeGoKimiK3Catalog(c *Config) (changed bool) {
1528 if c == nil {
1529 return false
1530 }
1531 for i := range c.Providers {
1532 p := &c.Providers[i]
1533 presetID := strings.TrimSpace(p.PresetID)
1534 if (presetID != "opencode-go" && (presetID != "" || strings.TrimSpace(p.Name) != "opencode-go")) ||
1535 !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
1536 normalizedBaseURLForMigration(p.BaseURL) != "https://opencode.ai/zen/go/v1" ||
1537 !stringSlicesEqual(p.Models, legacyOpenCodeGoModels) ||
1538 strings.TrimSpace(p.Model) != "" {
1539 continue
1540 }
1541 p.Models = append([]string(nil), opencodeGoModels...)
1542 p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, nil)
1543 mergeMissingKimiK3Override(p, ProviderModelOverride{
1544 ReasoningProtocol: ReasoningProtocolOpenAI,
1545 SupportedEfforts: []string{"high", "max"},
1546 DefaultEffort: "max",
1547 ContextWindow: 1_048_576,
1548 })
1549 changed = true
1550 }
1551 return changed
1552 }
1553
1554 // normalizeLegacyOpenCodeGoVisionCatalog upgrades only the untouched Chat
1555 // catalog that predates OpenCode Go's DeepSeek vision SKU. Custom model lists
1556 // and explicit image-input choices remain user-owned.
1557 func normalizeLegacyOpenCodeGoVisionCatalog(c *Config) (changed bool) {
1558 if c == nil {
1559 return false
1560 }
1561 for i := range c.Providers {
1562 p := &c.Providers[i]
1563 presetID := strings.TrimSpace(p.PresetID)
1564 if (presetID != "opencode-go" && (presetID != "" || strings.TrimSpace(p.Name) != "opencode-go")) ||
1565 !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
1566 normalizedBaseURLForMigration(p.BaseURL) != "https://opencode.ai/zen/go/v1" ||
1567 !stringSlicesEqual(p.Models, preVisionOpenCodeGoModels) ||
1568 strings.TrimSpace(p.Model) != "" {
1569 continue
1570 }
1571 p.Models = append([]string(nil), opencodeGoModels...)
1572 if p.VisionModels == nil || stringSlicesEqual(p.VisionModels, preVisionOpenCodeGoVisionModels) {
1573 p.VisionModels = append([]string(nil), opencodeGoVisionModels...)
1574 }
1575 mergeMissingOpenCodeGoVisionOverride(p)
1576 changed = true
1577 }
1578 return changed
1579 }
1580
1581 func mergeMissingOpenCodeGoVisionOverride(p *ProviderEntry) {
1582 if p.ModelOverrides == nil {
1583 p.ModelOverrides = map[string]ProviderModelOverride{}
1584 }
1585 const model = "deepseek-v4-flash-vision-exp"
1586 key := model
1587 for candidate := range p.ModelOverrides {
1588 if strings.EqualFold(strings.TrimSpace(candidate), model) {
1589 key = candidate
1590 break
1591 }
1592 }
1593 override := p.ModelOverrides[key]
1594 if strings.TrimSpace(override.ReasoningProtocol) == "" {
1595 override.ReasoningProtocol = ReasoningProtocolDeepSeek
1596 }
1597 if override.SupportedEfforts == nil {
1598 override.SupportedEfforts = []string{"disabled", "low", "high", "max"}
1599 }
1600 if strings.TrimSpace(override.DefaultEffort) == "" && containsString(normalizedEffortLevels(override.SupportedEfforts), "high") {
1601 override.DefaultEffort = "high"
1602 }
1603 if override.ContextWindow <= 0 {
1604 override.ContextWindow = 1_000_000
1605 }
1606 p.ModelOverrides[key] = override
1607 }
1608
1609 func normalizeLegacyMimoProviderCatalogs(c *Config) bool {
1610 if c == nil {
1611 return false
1612 }
1613 changed := false
1614 for i := range c.Providers {
1615 p := &c.Providers[i]
1616 if legacyMimoProviderName(p.Name) == "" || len(p.Models) > 0 {
1617 continue
1618 }
1619 switch officialProviderHost(p.BaseURL) {
1620 case "api.xiaomimimo.com":
1621 if applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{"mimo-v2.5", "mimo-v2-omni"}, "mimo-v2.5-pro") {
1622 changed = true
1623 }
1624 case "token-plan-cn.xiaomimimo.com":
1625 if applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{"mimo-v2.5"}, "mimo-v2.5-pro") {
1626 changed = true
1627 }
1628 }
1629 }
1630 return changed
1631 }
1632
1633 func applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {
1634 if p == nil || len(models) == 0 {
1635 return false
1636 }
1637 beforeModels := append([]string(nil), p.Models...)
1638 beforeVision := append([]string(nil), p.VisionModels...)
1639 beforeDefault := p.Default
1640 beforeModel := p.Model
1641 beforeWindow := p.ContextWindow
1642 beforeNoProxy := p.NoProxy
1643 beforePricesLen := len(p.Prices)
1644
1645 currentDefault := strings.TrimSpace(p.Default)
1646 if currentDefault == "" {
1647 currentDefault = strings.TrimSpace(p.Model)
1648 }
1649 p.Models = mergeModelLists(models, p.ModelList())
1650 p.Model = p.Models[0]
1651 p.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)
1652 p.VisionModels = mergeModelLists(visionModels, p.VisionModels)
1653 backfillOfficialContextWindow(p, 1_048_576)
1654 p.NoProxy = true
1655 if p.Prices == nil {
1656 p.Prices = mimoDomesticPrices(models)
1657 } else {
1658 for model, price := range mimoDomesticPrices(models) {
1659 if p.Prices[model] == nil {
1660 p.Prices[model] = price
1661 }
1662 }
1663 }
1664
1665 return !stringSlicesEqual(beforeModels, p.Models) ||
1666 !stringSlicesEqual(beforeVision, p.VisionModels) ||
1667 beforeDefault != p.Default ||
1668 beforeModel != p.Model ||
1669 beforeWindow != p.ContextWindow ||
1670 beforeNoProxy != p.NoProxy ||
1671 beforePricesLen != len(p.Prices)
1672 }
1673
1674 func stringSlicesEqual(a, b []string) bool {
1675 if len(a) != len(b) {
1676 return false
1677 }
1678 for i := range a {
1679 if a[i] != b[i] {
1680 return false
1681 }
1682 }
1683 return true
1684 }
1685
1686 func backfillDeepSeekAnthropicCapabilities(p *ProviderEntry) {
1687 if p == nil || !strings.EqualFold(strings.TrimSpace(p.Kind), "anthropic") ||
1688 !IsOfficialDeepSeekWebSearchEndpoint(p) {
1689 return
1690 }
1691 if strings.TrimSpace(p.Thinking) == "" {
1692 p.Thinking = "enabled"
1693 }
1694 }
1695
1696 func officialProviderHost(baseURL string) string {
1697 u, err := url.Parse(strings.TrimSpace(baseURL))
1698 if err != nil {
1699 return ""
1700 }
1701 return strings.ToLower(u.Hostname())
1702 }
1703
1704 func ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {
1705 if p == nil {
1706 return
1707 }
1708 // If the user has explicitly curated a model list (via Settings), respect
1709 // that choice and do not merge additional required models.
1710 if len(p.Models) > 0 {
1711 return
1712 }
1713 models := mergeModelLists(required, p.ModelList())
1714 if len(models) == 0 {
1715 return
1716 }
1717 p.Model = models[0]
1718 if len(models) > 1 {
1719 p.Models = models
1720 p.Default = firstKnownModel(p.Default, models, fallbackDefault)
1721 return
1722 }
1723 p.Models = nil
1724 p.Default = ""
1725 }
1726
1727 func legacyOfficialProviderModel(name string) string {
1728 switch strings.TrimSpace(name) {
1729 case "deepseek-flash":
1730 return "deepseek-v4-flash"
1731 case "deepseek-pro":
1732 return "deepseek-v4-pro"
1733 case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro":
1734 return "mimo-v2.5-pro"
1735 case "mimo-flash":
1736 return "mimo-v2.5"
1737 default:
1738 return ""
1739 }
1740 }
1741
1742 func normalizeLegacyMimoCustomProviders(c *Config) bool {
1743 return normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)
1744 }
1745
1746 // NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible
1747 // MiMo providers needed by legacy refs that live outside reasonix.toml, such as
1748 // restored desktop tab state.
1749 func NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {
1750 return normalizeLegacyMimoCustomProvidersForRefs(c, refs...)
1751 }
1752
1753 func normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {
1754 if c == nil {
1755 return false
1756 }
1757 needed := map[string]bool{}
1758 addRef := func(ref string) {
1759 if name := legacyMimoProviderNameForRef(ref); name != "" {
1760 needed[name] = true
1761 }
1762 }
1763 for _, ref := range refs {
1764 addRef(ref)
1765 }
1766 changed := normalizeLegacyMimoProviderCatalogs(c)
1767 for name := range needed {
1768 if _, ok := c.Provider(name); ok {
1769 continue
1770 }
1771 c.Providers = append(c.Providers, legacyMimoCustomProvider(name))
1772 changed = true
1773 }
1774 if normalizeLegacyMimoProviderCatalogs(c) {
1775 changed = true
1776 }
1777 return changed
1778 }
1779
1780 func legacyMimoConfigRefs(c *Config) []string {
1781 if c == nil {
1782 return nil
1783 }
1784 refs := []string{
1785 c.DefaultModel,
1786 c.Agent.PlannerModel,
1787 c.Agent.VisionModel,
1788 c.Agent.WebSearchModel,
1789 c.Agent.SubagentModel,
1790 c.Bot.Model,
1791 }
1792 for _, ref := range c.Agent.SubagentModels {
1793 refs = append(refs, ref)
1794 }
1795 for _, conn := range c.Bot.Connections {
1796 refs = append(refs, conn.Model)
1797 }
1798 refs = append(refs, c.Desktop.ProviderAccess...)
1799 return refs
1800 }
1801
1802 func legacyMimoProviderName(ref string) string {
1803 switch strings.TrimSpace(ref) {
1804 case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro", "mimo-flash":
1805 return strings.TrimSpace(ref)
1806 default:
1807 return ""
1808 }
1809 }
1810
1811 func legacyMimoProviderNameForRef(ref string) string {
1812 ref = strings.TrimSpace(ref)
1813 if ref == "" {
1814 return ""
1815 }
1816 providerName, _, hasModel := strings.Cut(ref, "/")
1817 if name := legacyMimoProviderName(providerName); name != "" {
1818 return name
1819 }
1820 if hasModel {
1821 return ""
1822 }
1823 switch ref {
1824 case "mimo-v2.5-pro":
1825 return "mimo-pro"
1826 case "mimo-v2.5":
1827 return "mimo-flash"
1828 case "mimo-v2-omni":
1829 return "mimo-api"
1830 default:
1831 return ""
1832 }
1833 }
1834
1835 func legacyMimoAPIModels() []string {
1836 return []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}
1837 }
1838
1839 func legacyMimoTokenPlanModels() []string {
1840 return []string{"mimo-v2.5-pro", "mimo-v2.5"}
1841 }
1842
1843 func legacyMimoCustomProvider(name string) ProviderEntry {
1844 switch strings.TrimSpace(name) {
1845 case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api":
1846 models := legacyMimoAPIModels()
1847 return ProviderEntry{
1848 Name: strings.TrimSpace(name),
1849 Kind: "openai",
1850 BaseURL: "https://api.xiaomimimo.com/v1",
1851 Models: models,
1852 VisionModels: []string{"mimo-v2.5", "mimo-v2-omni"},
1853 Default: "mimo-v2.5-pro",
1854 APIKeyEnv: "MIMO_API_KEY",
1855 ContextWindow: 1_048_576,
1856 Prices: mimoDomesticPrices(models),
1857 NoProxy: true,
1858 }
1859 case "mimo-token-plan":
1860 models := legacyMimoTokenPlanModels()
1861 return ProviderEntry{
1862 Name: "mimo-token-plan",
1863 Kind: "openai",
1864 BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
1865 Models: models,
1866 VisionModels: []string{"mimo-v2.5"},
1867 Default: "mimo-v2.5-pro",
1868 APIKeyEnv: "MIMO_API_KEY",
1869 ContextWindow: 1_048_576,
1870 Prices: mimoDomesticPrices(models),
1871 NoProxy: true,
1872 }
1873 case "mimo-flash":
1874 return ProviderEntry{Name: "mimo-flash", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}
1875 default:
1876 return ProviderEntry{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}
1877 }
1878 }
1879
1880 func normalizeDesktopOfficialProviderAccess(c *Config) {
1881 if c == nil || len(c.Desktop.ProviderAccess) == 0 {
1882 return
1883 }
1884 canCanonicalizeDeepSeek := canCanonicalizeLegacyDeepSeekProviders(c)
1885 _, hasCanonicalDeepSeek := c.Provider("deepseek")
1886 legacyDeepSeek := officialLegacyDeepSeekProviders(c)
1887 seen := desktopProviderAccessMap(nil)
1888 next := make([]string, 0, len(c.Desktop.ProviderAccess))
1889 for _, name := range c.Desktop.ProviderAccess {
1890 name = strings.TrimSpace(name)
1891 if name == "deepseek" && !canCanonicalizeDeepSeek && !hasCanonicalDeepSeek && len(legacyDeepSeek) > 0 {
1892 for _, legacy := range legacyDeepSeek {
1893 if !seen[legacy.Name] {
1894 seen[legacy.Name] = true
1895 next = append(next, legacy.Name)
1896 }
1897 }
1898 continue
1899 }
1900 if CanonicalDesktopOfficialProviderName(name) != "deepseek" || name == "deepseek" || canCanonicalizeDeepSeek {
1901 name = desktopProviderAccessNameForConfig(c, name)
1902 }
1903 if name == "" || seen[name] {
1904 continue
1905 }
1906 seen[name] = true
1907 next = append(next, name)
1908 }
1909 c.Desktop.ProviderAccess = next
1910 if seen["deepseek"] {
1911 ensureDeepSeekOfficialProvider(c)
1912 }
1913 normalizeLegacyMimoProviderCatalogs(c)
1914 retargetAccess := maps.Clone(seen)
1915 if p, ok := c.Provider("deepseek"); !canCanonicalizeDeepSeek || !ok || officialProviderKind(p) != "deepseek" {
1916 delete(retargetAccess, "deepseek")
1917 }
1918 retargetDesktopOfficialRefs(c, retargetAccess)
1919 }
1920
1921 // NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list
1922 // for configs written before Settings tracked explicit provider access. Callers
1923 // should only use this when they know the TOML did not declare provider_access;
1924 // an explicit empty list means the user removed all access entries.
1925 func NormalizeLegacyDesktopProviderAccess(c *Config) {
1926 if c == nil || len(c.Desktop.ProviderAccess) > 0 {
1927 return
1928 }
1929 seen := desktopProviderAccessMap(nil)
1930 var access []string
1931 add := func(name string) {
1932 name = desktopProviderAccessNameForConfig(c, name)
1933 if name == "" || seen[name] {
1934 return
1935 }
1936 seen[name] = true
1937 access = append(access, name)
1938 }
1939 addRef := func(ref string) {
1940 if entry, ok := c.ResolveModel(ref); ok {
1941 if !entry.Configured() {
1942 return
1943 }
1944 add(entry.Name)
1945 }
1946 }
1947 addRef(c.DefaultModel)
1948 addRef(c.Agent.PlannerModel)
1949 addRef(c.Agent.VisionModel)
1950 addRef(c.Agent.WebSearchModel)
1951 addRef(c.Agent.SubagentModel)
1952 for _, ref := range c.Agent.SubagentModels {
1953 addRef(ref)
1954 }
1955 addRef(c.Bot.Model)
1956 for _, conn := range c.Bot.Connections {
1957 addRef(conn.Model)
1958 }
1959 for i := range c.Providers {
1960 p := &c.Providers[i]
1961 if legacyMimoProviderName(p.Name) != "" && len(p.ModelList()) > 0 {
1962 add(p.Name)
1963 continue
1964 }
1965 if p.Configured() && len(p.ModelList()) > 0 {
1966 add(p.Name)
1967 }
1968 }
1969 if len(access) == 0 {
1970 return
1971 }
1972 c.Desktop.ProviderAccess = access
1973 normalizeDesktopOfficialProviderAccess(c)
1974 }
1975
1976 func canonicalDesktopOfficialProviderName(name string) string {
1977 switch strings.TrimSpace(name) {
1978 case "deepseek-flash", "deepseek-pro":
1979 return "deepseek"
1980 default:
1981 return strings.TrimSpace(name)
1982 }
1983 }
1984
1985 func desktopProviderAccessNameForConfig(c *Config, name string) string {
1986 name = strings.TrimSpace(name)
1987 if name == "" {
1988 return ""
1989 }
1990 canonical := canonicalDesktopOfficialProviderName(name)
1991 if canonical == name {
1992 return name
1993 }
1994 if c == nil {
1995 return canonical
1996 }
1997 if p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {
1998 return name
1999 }
2000 return canonical
2001 }
2002
2003 func providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {
2004 if p == nil {
2005 return false
2006 }
2007 switch canonical {
2008 case "deepseek":
2009 return isCanonicalizableLegacyDeepSeekProvider(p)
2010 default:
2011 return false
2012 }
2013 }
2014
2015 // CanonicalDesktopOfficialProviderName returns the Settings Center provider ID
2016 // for built-in official provider aliases.
2017 func CanonicalDesktopOfficialProviderName(name string) string {
2018 return canonicalDesktopOfficialProviderName(name)
2019 }
2020
2021 func desktopProviderAccessMap(names []string) map[string]bool {
2022 out := map[string]bool{}
2023 for _, name := range names {
2024 name = strings.TrimSpace(name)
2025 if name != "" {
2026 out[name] = true
2027 }
2028 }
2029 return out
2030 }
2031
2032 func ensureDeepSeekOfficialProvider(c *Config) {
2033 if p, ok := c.Provider("deepseek"); ok {
2034 if officialProviderKind(p) == "deepseek" {
2035 backfillOfficialContextWindow(p, 1_000_000)
2036 }
2037 return
2038 }
2039 if !canCanonicalizeLegacyDeepSeekProviders(c) {
2040 return
2041 }
2042 entry := ProviderEntry{
2043 Name: "deepseek",
2044 Kind: "anthropic",
2045 BaseURL: deepSeekAnthropicBaseURL,
2046 Models: append([]string(nil), deepSeekOfficialModels...),
2047 Default: "deepseek-flash",
2048 APIKeyEnv: "DEEPSEEK_API_KEY",
2049 BalanceURL: "https://api.deepseek.com/user/balance",
2050 Thinking: "enabled",
2051 WebSearch: boolPointer(true),
2052 ContextWindow: 1_000_000,
2053 Prices: deepSeekV4PricesForConfig(c),
2054 }
2055 legacyProviders := officialLegacyDeepSeekProviders(c)
2056 if len(legacyProviders) > 0 {
2057 entry = officialProviderFromLegacy(entry, legacyProviders[0])
2058 currency := c.DeepSeekOfficialPricingCurrency()
2059 if c.DesktopCurrency() == "" && legacyProviders[0].persistedOfficialCurrency != "" {
2060 currency = legacyProviders[0].persistedOfficialCurrency
2061 entry.persistedOfficialCurrency = currency
2062 }
2063 entry.Prices = DeepSeekV4PricesForCurrency(currency)
2064 for _, old := range legacyProviders {
2065 entry.Models = mergeModelLists(entry.Models, old.ModelList())
2066 mergeLegacyDeepSeekModelConfiguration(&entry, old)
2067 }
2068 entry.Default = preferredLegacyDeepSeekDefault(legacyProviders, entry.Models, entry.Default)
2069 }
2070 backfillOfficialContextWindow(&entry, 1_000_000)
2071 c.Providers = append(c.Providers, entry)
2072 }
2073
2074 func isOpenAIProviderKind(e *ProviderEntry) bool {
2075 return e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), "openai")
2076 }
2077
2078 func mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {
2079 // If the user has explicitly curated a model list (via Settings), respect
2080 // that choice and do not merge additional curated models.
2081 if len(e.Models) > 0 {
2082 return
2083 }
2084 currentDefault := e.Default
2085 if strings.TrimSpace(currentDefault) == "" {
2086 currentDefault = e.Model
2087 }
2088 e.Models = mergeModelLists(models, e.ModelList())
2089 e.Default = firstKnownModel(currentDefault, e.Models, fallback)
2090 }
2091
2092 func backfillOfficialContextWindow(e *ProviderEntry, fallback int) {
2093 if e != nil && e.ContextWindow <= 0 {
2094 e.ContextWindow = fallback
2095 }
2096 }
2097
2098 func officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {
2099 if old == nil {
2100 return entry
2101 }
2102 // Start from the legacy entry so current and future transport fields are not
2103 // silently dropped from the effective canonical provider. Identity, catalog,
2104 // pricing and capability fields are merged model by model below.
2105 legacy := cloneProviderEntry(*old)
2106 legacy.Name = entry.Name
2107 legacy.Model = ""
2108 legacy.Models = append([]string(nil), entry.Models...)
2109 legacy.Default = entry.Default
2110 legacy.ContextWindow = entry.ContextWindow
2111 legacy.MaxOutputTokens = entry.MaxOutputTokens
2112 legacy.Price = nil
2113 legacy.Prices = clonePricingMap(entry.Prices)
2114 legacy.ReasoningProtocol = entry.ReasoningProtocol
2115 legacy.SupportedEfforts = append([]string(nil), entry.SupportedEfforts...)
2116 legacy.DefaultEffort = entry.DefaultEffort
2117 legacy.Vision = entry.Vision
2118 legacy.VisionModels = append([]string(nil), entry.VisionModels...)
2119 legacy.ModelOverrides = cloneModelOverrideMap(entry.ModelOverrides)
2120 return legacy
2121 }
2122
2123 func officialLegacyDeepSeekProviders(c *Config) []*ProviderEntry {
2124 if c == nil {
2125 return nil
2126 }
2127 out := make([]*ProviderEntry, 0, 2)
2128 for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
2129 if p, ok := c.Provider(name); ok && isCanonicalizableLegacyDeepSeekProvider(p) {
2130 out = append(out, p)
2131 }
2132 }
2133 return out
2134 }
2135
2136 func isCanonicalizableLegacyDeepSeekProvider(p *ProviderEntry) bool {
2137 if p == nil {
2138 return false
2139 }
2140 switch strings.ToLower(strings.TrimSpace(p.Kind)) {
2141 case "openai":
2142 return isOfficialDeepSeekOpenAIEndpoint(p.BaseURL)
2143 case "anthropic":
2144 return IsOfficialDeepSeekWebSearchEndpoint(p)
2145 default:
2146 return false
2147 }
2148 }
2149
2150 func canCanonicalizeLegacyDeepSeekProviders(c *Config) bool {
2151 if c == nil {
2152 return true
2153 }
2154 legacy := officialLegacyDeepSeekProviders(c)
2155 if canonical, ok := c.Provider("deepseek"); ok {
2156 if officialProviderKind(canonical) != "deepseek" {
2157 return false
2158 }
2159 for _, old := range legacy {
2160 if !legacyDeepSeekProviderWideFieldsEqual(canonical, old) ||
2161 !legacyDeepSeekModelFieldsCompatibleIgnoringDefault(canonical, old) {
2162 return false
2163 }
2164 }
2165 }
2166 for i := 1; i < len(legacy); i++ {
2167 if !legacyDeepSeekProviderWideFieldsEqual(legacy[0], legacy[i]) ||
2168 !strings.EqualFold(strings.TrimSpace(legacy[0].Effort), strings.TrimSpace(legacy[i].Effort)) ||
2169 !legacyDeepSeekModelFieldsCompatible(legacy[0], legacy[i]) {
2170 return false
2171 }
2172 }
2173 return true
2174 }
2175
2176 func legacyDeepSeekModelFieldsCompatibleIgnoringDefault(a, b *ProviderEntry) bool {
2177 if a == nil || b == nil {
2178 return a == b
2179 }
2180 left := cloneProviderEntry(*a)
2181 right := cloneProviderEntry(*b)
2182 left.Default = ""
2183 right.Default = ""
2184 return legacyDeepSeekModelFieldsCompatible(&left, &right)
2185 }
2186
2187 func legacyDeepSeekProviderWideFieldsEqual(a, b *ProviderEntry) bool {
2188 if a == nil || b == nil {
2189 return a == b
2190 }
2191 left := legacyDeepSeekProviderWideProjection(a)
2192 right := legacyDeepSeekProviderWideProjection(b)
2193 return reflect.DeepEqual(left, right)
2194 }
2195
2196 func legacyDeepSeekProviderWideProjection(entry *ProviderEntry) ProviderEntry {
2197 out := cloneProviderEntry(*entry)
2198 out.Name = ""
2199 out.Kind = strings.ToLower(strings.TrimSpace(out.Kind))
2200 out.BaseURL = normalizedBaseURLForMigration(out.BaseURL)
2201 out.ChatURL = strings.TrimSpace(out.ChatURL)
2202 out.RequestURL = strings.TrimSpace(out.RequestURL)
2203 out.ModelsURL = strings.TrimSpace(out.ModelsURL)
2204 out.APIKeyEnv = strings.TrimSpace(out.APIKeyEnv)
2205 out.BalanceURL = normalizedDeepSeekBalanceURL(out.BalanceURL)
2206 out.ResponsesMode = strings.TrimSpace(out.ResponsesMode)
2207 out.Thinking = strings.TrimSpace(out.Thinking)
2208 out.VisionDetail = strings.TrimSpace(out.VisionDetail)
2209
2210 // Effort is a per-provider selection that /effort writes to the canonical
2211 // member, so it cannot decide canonical-vs-legacy equality (#8337). Legacy
2212 // members are compared on it separately: merging keeps only the first's.
2213 out.Effort = ""
2214
2215 // These fields can be represented independently for every model in the
2216 // canonical provider. They are compared by legacyDeepSeekModelFieldsCompatible.
2217 out.Model = ""
2218 out.Models = nil
2219 out.Default = ""
2220 out.ContextWindow = 0
2221 out.MaxOutputTokens = 0
2222 out.Price = nil
2223 out.Prices = nil
2224 out.ReasoningProtocol = ""
2225 out.SupportedEfforts = nil
2226 out.DefaultEffort = ""
2227 out.Vision = false
2228 out.VisionModels = nil
2229 out.ModelOverrides = nil
2230 out.visionOverride = nil
2231 out.resolvedAPIKey = ""
2232 out.resolvedSource = CredentialSource{}
2233 return out
2234 }
2235
2236 func normalizedDeepSeekBalanceURL(raw string) string {
2237 raw = strings.TrimRight(strings.TrimSpace(raw), "/")
2238 if raw == "" {
2239 return deepSeekOfficialBalanceURL
2240 }
2241 return raw
2242 }
2243
2244 type legacyDeepSeekModelFields struct {
2245 contextWindowSet bool
2246 contextWindow int
2247 maxOutputTokensSet bool
2248 maxOutputTokens int
2249 priceSet bool
2250 price *provider.Pricing
2251 reasoningProtocolSet bool
2252 reasoningProtocol string
2253 supportedEffortsSet bool
2254 supportedEfforts []string
2255 defaultEffortSet bool
2256 defaultEffort string
2257 visionSet bool
2258 vision bool
2259 }
2260
2261 func legacyDeepSeekModelFieldsCompatible(a, b *ProviderEntry) bool {
2262 if a == nil || b == nil {
2263 return a == b
2264 }
2265 if left, right := strings.TrimSpace(a.Default), strings.TrimSpace(b.Default); left != "" && right != "" && left != right {
2266 return false
2267 }
2268 models := map[string]string{}
2269 add := func(model string) {
2270 model = strings.TrimSpace(model)
2271 if model != "" {
2272 models[strings.ToLower(model)] = model
2273 }
2274 }
2275 for _, entry := range []*ProviderEntry{a, b} {
2276 for _, model := range entry.ModelList() {
2277 add(model)
2278 }
2279 for _, model := range entry.VisionModels {
2280 add(model)
2281 }
2282 for model := range entry.Prices {
2283 add(model)
2284 }
2285 for model := range entry.ModelOverrides {
2286 add(model)
2287 }
2288 }
2289 for _, model := range models {
2290 left, leftSet := legacyDeepSeekModelFieldProjection(a, model)
2291 right, rightSet := legacyDeepSeekModelFieldProjection(b, model)
2292 if leftSet && rightSet && !legacyDeepSeekModelFieldProjectionsCompatible(left, right) {
2293 return false
2294 }
2295 }
2296 return true
2297 }
2298
2299 func legacyDeepSeekModelFieldProjection(entry *ProviderEntry, model string) (legacyDeepSeekModelFields, bool) {
2300 var out legacyDeepSeekModelFields
2301 if entry == nil {
2302 return out, false
2303 }
2304 listed := entry.HasModel(model)
2305 if listed {
2306 out.contextWindowSet = true
2307 out.contextWindow = entry.ContextWindow
2308 if out.contextWindow <= 0 {
2309 out.contextWindow = 1_000_000
2310 }
2311 out.maxOutputTokensSet = true
2312 out.maxOutputTokens = entry.MaxOutputTokens
2313 out.reasoningProtocolSet = true
2314 out.reasoningProtocol = strings.TrimSpace(entry.ReasoningProtocol)
2315 out.supportedEffortsSet = true
2316 out.supportedEfforts = append([]string(nil), entry.SupportedEfforts...)
2317 out.defaultEffortSet = true
2318 out.defaultEffort = strings.TrimSpace(entry.DefaultEffort)
2319 out.visionSet = true
2320 out.vision = entry.Vision || entry.HasVisionModel(model)
2321 if price := entry.PriceForModel(model); price != nil {
2322 out.priceSet = true
2323 out.price = price
2324 }
2325 }
2326 if price, ok := pricingForModelKey(entry.Prices, model); ok {
2327 out.priceSet = true
2328 out.price = clonePricing(price)
2329 }
2330 if override, ok := entry.modelOverrideForModel(model); ok {
2331 if override.ContextWindow > 0 {
2332 out.contextWindowSet = true
2333 out.contextWindow = override.ContextWindow
2334 }
2335 if override.MaxOutputTokens != 0 {
2336 out.maxOutputTokensSet = true
2337 out.maxOutputTokens = override.MaxOutputTokens
2338 }
2339 if strings.TrimSpace(override.ReasoningProtocol) != "" {
2340 out.reasoningProtocolSet = true
2341 out.reasoningProtocol = strings.TrimSpace(override.ReasoningProtocol)
2342 }
2343 if override.SupportedEfforts != nil {
2344 out.supportedEffortsSet = true
2345 out.supportedEfforts = append([]string(nil), override.SupportedEfforts...)
2346 out.defaultEffortSet = true
2347 out.defaultEffort = strings.TrimSpace(override.DefaultEffort)
2348 }
2349 if override.Vision != nil {
2350 out.visionSet = true
2351 out.vision = *override.Vision
2352 }
2353 }
2354 return out, listed || out.contextWindowSet || out.maxOutputTokensSet || out.priceSet ||
2355 out.reasoningProtocolSet || out.supportedEffortsSet || out.defaultEffortSet || out.visionSet
2356 }
2357
2358 func pricingForModelKey(prices map[string]*provider.Pricing, model string) (*provider.Pricing, bool) {
2359 for key, price := range prices {
2360 if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(model)) {
2361 return price, true
2362 }
2363 }
2364 return nil, false
2365 }
2366
2367 func legacyDeepSeekModelFieldProjectionsCompatible(a, b legacyDeepSeekModelFields) bool {
2368 return (!a.contextWindowSet || !b.contextWindowSet || a.contextWindow == b.contextWindow) &&
2369 (!a.maxOutputTokensSet || !b.maxOutputTokensSet || a.maxOutputTokens == b.maxOutputTokens) &&
2370 (!a.priceSet || !b.priceSet || reflect.DeepEqual(a.price, b.price)) &&
2371 (!a.reasoningProtocolSet || !b.reasoningProtocolSet || a.reasoningProtocol == b.reasoningProtocol) &&
2372 (!a.supportedEffortsSet || !b.supportedEffortsSet || slices.Equal(a.supportedEfforts, b.supportedEfforts)) &&
2373 (!a.defaultEffortSet || !b.defaultEffortSet || a.defaultEffort == b.defaultEffort) &&
2374 (!a.visionSet || !b.visionSet || a.vision == b.vision)
2375 }
2376
2377 func preferredLegacyDeepSeekDefault(entries []*ProviderEntry, models []string, fallback string) string {
2378 for _, entry := range entries {
2379 if entry == nil {
2380 continue
2381 }
2382 candidate := strings.TrimSpace(entry.Default)
2383 if candidate != "" && slices.Contains(models, candidate) {
2384 return candidate
2385 }
2386 }
2387 return firstKnownModel(fallback, models, "deepseek-v4-flash")
2388 }
2389
2390 func mergeLegacyDeepSeekModelConfiguration(entry, old *ProviderEntry) {
2391 if entry == nil || old == nil {
2392 return
2393 }
2394 if entry.Prices == nil {
2395 entry.Prices = map[string]*provider.Pricing{}
2396 }
2397 if entry.ModelOverrides == nil {
2398 entry.ModelOverrides = map[string]ProviderModelOverride{}
2399 }
2400 entry.VisionModels = mergeModelLists(entry.VisionModels, old.VisionModels)
2401 for model, price := range old.Prices {
2402 entry.Prices[model] = clonePricing(price)
2403 }
2404 for _, model := range old.ModelList() {
2405 model = strings.TrimSpace(model)
2406 if model == "" {
2407 continue
2408 }
2409 if price := old.PriceForModel(model); price != nil {
2410 entry.Prices[model] = price
2411 }
2412 override := entry.ModelOverrides[model]
2413 if old.ContextWindow > 0 && old.ContextWindow != entry.ContextWindow {
2414 override.ContextWindow = old.ContextWindow
2415 }
2416 if old.MaxOutputTokens != entry.MaxOutputTokens {
2417 override.MaxOutputTokens = old.MaxOutputTokens
2418 }
2419 if protocol := strings.TrimSpace(old.ReasoningProtocol); protocol != "" {
2420 override.ReasoningProtocol = protocol
2421 }
2422 if len(old.SupportedEfforts) > 0 {
2423 override.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)
2424 override.DefaultEffort = old.DefaultEffort
2425 }
2426 if old.Vision || old.HasVisionModel(model) {
2427 vision := true
2428 override.Vision = &vision
2429 }
2430 if explicit, ok := old.modelOverrideForModel(model); ok {
2431 mergeProviderModelOverride(&override, explicit)
2432 }
2433 entry.ModelOverrides[model] = override
2434 }
2435 for model, override := range old.ModelOverrides {
2436 if old.HasModel(model) {
2437 continue
2438 }
2439 current := entry.ModelOverrides[model]
2440 mergeProviderModelOverride(&current, override)
2441 entry.ModelOverrides[model] = current
2442 }
2443 }
2444
2445 func mergeProviderModelOverride(dst *ProviderModelOverride, src ProviderModelOverride) {
2446 if dst == nil {
2447 return
2448 }
2449 if strings.TrimSpace(src.ReasoningProtocol) != "" {
2450 dst.ReasoningProtocol = src.ReasoningProtocol
2451 }
2452 if len(src.SupportedEfforts) > 0 {
2453 dst.SupportedEfforts = append([]string(nil), src.SupportedEfforts...)
2454 dst.DefaultEffort = src.DefaultEffort
2455 }
2456 if src.Vision != nil {
2457 vision := *src.Vision
2458 dst.Vision = &vision
2459 }
2460 if src.ContextWindow > 0 {
2461 dst.ContextWindow = src.ContextWindow
2462 }
2463 if src.MaxOutputTokens != 0 {
2464 dst.MaxOutputTokens = src.MaxOutputTokens
2465 }
2466 }
2467
2468 func mergeModelLists(primary, extra []string) []string {
2469 seen := map[string]bool{}
2470 out := make([]string, 0, len(primary))
2471 for _, list := range [][]string{primary, extra} {
2472 for _, model := range list {
2473 model = strings.TrimSpace(model)
2474 if model == "" || seen[model] {
2475 continue
2476 }
2477 seen[model] = true
2478 out = append(out, model)
2479 }
2480 }
2481 return out
2482 }
2483
2484 func firstKnownModel(current string, models []string, fallback string) string {
2485 current = strings.TrimSpace(current)
2486 if slices.Contains(models, current) {
2487 return current
2488 }
2489 if slices.Contains(models, fallback) {
2490 return fallback
2491 }
2492 if len(models) > 0 {
2493 return models[0]
2494 }
2495 return ""
2496 }
2497
2498 func retargetDesktopOfficialRefs(c *Config, access map[string]bool) {
2499 c.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)
2500 c.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)
2501 c.Agent.VisionModel = retargetDesktopOfficialRef(c.Agent.VisionModel, access)
2502 c.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)
2503 for skill, ref := range c.Agent.SubagentModels {
2504 c.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)
2505 }
2506 }
2507
2508 func retargetDesktopOfficialRef(ref string, access map[string]bool) string {
2509 ref = strings.TrimSpace(ref)
2510 if ref == "" {
2511 return ""
2512 }
2513 provider, model, hasModel := strings.Cut(ref, "/")
2514 switch provider {
2515 case "deepseek-flash":
2516 if !access["deepseek"] {
2517 return ref
2518 }
2519 if !hasModel || strings.TrimSpace(model) == "" {
2520 model = "deepseek-v4-flash"
2521 }
2522 return "deepseek/" + model
2523 case "deepseek-pro":
2524 if !access["deepseek"] {
2525 return ref
2526 }
2527 if !hasModel || strings.TrimSpace(model) == "" {
2528 model = "deepseek-v4-pro"
2529 }
2530 return "deepseek/" + model
2531 default:
2532 return ref
2533 }
2534 }
2535
2535 lines GO