返回 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 := cfg.Remote.Clone()
152 globalDesktopLanguage := cfg.Desktop.Language
153 globalPricingCurrency := cfg.Desktop.Currency
154 globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
155 globalTelemetry, globalLegacyAnchorSafetyGate := cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate
156
157 tomlSources = append(tomlSources, projectTOML)
158 projectMeta, err := mergeTOML(cfg, projectTOML)
159 if err != nil {
160 // Project config damage is isolated to this workspace: continue with
161 // user/global config so other tabs stay available.
162 cfg.addLoadWarning(fmt.Sprintf(
163 "project config %s is invalid (%v); ignored for this workspace",
164 projectTOML, err,
165 ))
166 // Drop the project path from later multi-file merges so a broken TOML
167 // cannot fail plugin/provider re-merges.
168 tomlSources = tomlSources[:len(tomlSources)-1]
169 } else if projectMeta.IsDefined("agent", "system_prompt_file") {
170 cfg.systemPromptFileSource = promptFileSourceProject
171 }
172 // The native CLI update channel controls the one user-installed binary.
173 // A repository-local reasonix.toml must never switch that global choice.
174 cfg.CLI = globalCLI
175 // Secret protection is a user-global security control: a cloned repo's
176 // reasonix.toml must not be able to flip on the workflow-breaking env/path
177 // protections.
178 cfg.Secrets = globalSecrets
179 // Remote SSH hosts are equally user-global: a cloned repo's reasonix.toml
180 // must not be able to inject hosts, jump chains, or port forwards that
181 // steer where Reasonix opens connections.
182 cfg.Remote = globalRemote
183 // Desktop language and pricing currency are user-level regional preferences.
184 // A repository must not be able to alter how the user's spend is shown.
185 cfg.Desktop.Language = globalDesktopLanguage
186 cfg.Desktop.Currency = globalPricingCurrency
187 cfg.Billing.DisplayCurrency = globalBillingDisplayCurrency
188 // CLI telemetry is an explicit user-global privacy choice. Project config
189 // cannot opt a user in or out, including when the global value is absent.
190 cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate = globalTelemetry, globalLegacyAnchorSafetyGate
191 // TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds
192 // only the last file's. Re-merge by name across all sources (later wins) so a
193 // project reasonix.toml doesn't drop the global config's MCP servers.
194 // mergeTOMLPlugins only reads files; it does not run on-disk migrations.
195 plugins, err := mergeTOMLPlugins(tomlSources)
196 if err != nil {
197 cfg.addLoadWarning(fmt.Sprintf("plugin configuration could not be merged (%v); continuing without those entries", err))
198 } else {
199 cfg.Plugins = plugins
200 }
201 if providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {
202 cfg.addLoadWarning(fmt.Sprintf("provider configuration could not be merged (%v); keeping providers already loaded", err))
203 } else if ok {
204 cfg.Providers = providers
205 cfg.providerSources = providerSources
206 cfg.shadowedProjectProviders = shadowedProjectProviders
207 }
208 if access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {
209 cfg.addLoadWarning(fmt.Sprintf("provider access configuration could not be merged (%v)", err))
210 } else if ok {
211 cfg.Desktop.ProviderAccess = access
212 }
213
214 // Claude Code's .mcp.json (project root) is read last and merged into
215 // [[plugins]], so a server configured for Claude works here unchanged.
216 // Project reasonix.toml wins on a name collision; project .mcp.json wins
217 // over a same-name user-global entry (see mergeMCPJSON).
218 mcpFile := mcpJSONFile
219 if root != "." {
220 mcpFile = filepath.Join(root, mcpJSONFile)
221 }
222 entries, err := loadMCPJSON(mcpFile)
223 if err != nil {
224 cfg.addLoadWarning(fmt.Sprintf("project .mcp.json is invalid (%v); MCP servers from that file are ignored", err))
225 } else {
226 cfg.mergeMCPJSON(entries)
227 }
228
229 // Lowest priority before the one-time v1.9.1 MCP migration: the v0.x
230 // ~/.reasonix/config.json's mcpServers. Once the migration marker exists, the
231 // current config is authoritative even when it is empty; reading the legacy
232 // source again would resurrect servers the user removed from current config.
233 if !mcpGlobalMigrationComplete() {
234 cfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))
235 }
236 _ = mergeInstalledPluginPackages(cfg, root)
237 if err := normalizeRuntimeConfigWithMigrationJournal(cfg); err != nil {
238 return nil, err
239 }
240 if userDefaultModelExplicit {
241 restoreUnresolvableProjectDefaultModel(cfg, userDefaultModel)
242 }
243 cfg.CredentialsStore = credentialsStoreMode()
244 cfg.setExpansionEnv(expansionEnv)
245 if opts.loadCredentials {
246 resolveProviderCredentialsForRoot(root, cfg)
247 }
248 return cfg, nil
249 }
250
251 // LoadBuiltinDefaultsForRoot returns a read-only built-in-only configuration
252 // without reading or migrating user/project TOML. Diagnostic and recovery tools
253 // use it when configuration is malformed; it does not put the process into any
254 // degraded product "mode". Provider credentials still resolve only from
255 // Reasonix's global credential store.
256 func LoadBuiltinDefaultsForRoot(root string) *Config {
257 cfg := Default()
258 cfg.Plugins = nil
259 cfg.Skills = SkillsConfig{}
260 cfg.Bot.Enabled = false
261 cfg.Bot.Connections = nil
262 cfg.Bot.Routes = nil
263 cfg.Statusline.Command = ""
264 cfg.LSP.Enabled = false
265 cfg.setExpansionEnv(nil)
266 cfg.CredentialsStore = credentialsStoreMode()
267 resolveProviderCredentialsForRoot(root, cfg)
268 return cfg
269 }
270
271 // LoadRecoveryDefaultsForRoot is retained as an alias of LoadBuiltinDefaultsForRoot
272 // for older recovery call sites.
273 func LoadRecoveryDefaultsForRoot(root string) *Config {
274 return LoadBuiltinDefaultsForRoot(root)
275 }
276
277 func (c *Config) setExpansionEnv(env map[string]string) {
278 if c == nil {
279 return
280 }
281 c.expansionEnv = cloneStringMap(env)
282 for i := range c.Plugins {
283 c.Plugins[i].expansionEnv = c.expansionEnv
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 = normalizeLegacyKimiK3Catalog(cfg) || changed
794 changed = normalizeLegacyOpenCodeGoInstalls(cfg) || changed
795 changed = normalizeLegacyMimoCustomProviders(cfg) || changed
796 normalizeLegacyProviderModels(cfg)
797 normalizeDesktopOfficialProviderAccess(cfg)
798 normalizeOfficialDeepSeekModels(cfg)
799 migrateBillingDisplayCurrency(cfg)
800 freezeProviderBillingCurrencies(cfg)
801 applyDeepSeekOfficialDefaultPricing(cfg)
802 backfillDeepSeekOfficialPrices(cfg)
803 normalizeEffortConfig(cfg)
804 return changed
805 }
806
807 // normalizeRetiredMultiThresholdCompaction clears retired multi-threshold keys
808 // so they never reach the Agent. Disk migration removes them on ordinary start;
809 // loading still ignores them if migration could not rewrite the file.
810 func normalizeRetiredMultiThresholdCompaction(c *Config) bool {
811 if c == nil {
812 return false
813 }
814 changed := c.Agent.SoftCompactRatio != 0 ||
815 c.Agent.ToolResultSnipRatio != 0 ||
816 c.Agent.CompactForceRatio != 0 ||
817 c.Agent.ColdResumePrune != nil ||
818 strings.TrimSpace(c.Agent.ContextEditing) != ""
819 c.Agent.SoftCompactRatio = 0
820 c.Agent.ToolResultSnipRatio = 0
821 c.Agent.CompactForceRatio = 0
822 c.Agent.ColdResumePrune = nil
823 c.Agent.ContextEditing = ""
824 if c.Agent.CompactRatio <= 0 {
825 c.Agent.CompactRatio = Default().Agent.CompactRatio
826 changed = true
827 }
828 return changed
829 }
830
831 // normalizeRetiredAutoPlan keeps pre-v5 configs readable while enforcing the
832 // single explicit-plan experience. The deprecated fields remain in AgentConfig
833 // only so old TOML and older desktop payloads decode safely.
834 func normalizeRetiredAutoPlan(c *Config) bool {
835 if c == nil {
836 return false
837 }
838 changed := strings.TrimSpace(c.Agent.AutoPlan) != "" && !strings.EqualFold(strings.TrimSpace(c.Agent.AutoPlan), "off") ||
839 strings.TrimSpace(c.Agent.AutoPlanClassifier) != ""
840 c.Agent.AutoPlan = "off"
841 c.Agent.AutoPlanClassifier = ""
842 return changed
843 }
844
845 func loadDotEnvForEditPath(path string) {
846 path = strings.TrimSpace(path)
847 if path == "" || isUserConfigPath(path) {
848 loadDotEnv()
849 return
850 }
851 loadDotEnvForRoot(filepath.Dir(path))
852 }
853
854 // mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.
855 func mergeFile(cfg *Config, path string) error {
856 _, err := mergeFileSnapshot(cfg, path)
857 return err
858 }
859
860 // mergeFileSnapshot decodes one immutable read of a TOML file onto cfg and
861 // returns metadata from those exact bytes. Callers that derive source or
862 // precedence decisions from metadata must use this result instead of reading
863 // the path again: a config file may be atomically replaced between reads.
864 func mergeFileSnapshot(cfg *Config, path string) (toml.MetaData, error) {
865 return mergeFileSnapshotWithRead(cfg, path, fileencoding.ReadFileUTF8)
866 }
867
868 func mergeFileSnapshotWithRead(cfg *Config, path string, readFile func(string) ([]byte, error)) (toml.MetaData, error) {
869 resolved, exists, err := statConfigPath(path)
870 if err != nil {
871 return toml.MetaData{}, err
872 }
873 if !exists {
874 return toml.MetaData{}, nil
875 }
876 data, err := readFile(resolved)
877 if err != nil {
878 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
879 }
880 // BurntSushi/toml decodes struct fields incrementally and can leave earlier
881 // fields mutated when a later value has the wrong type. Validate the complete
882 // snapshot against a disposable Config before merging those same bytes into
883 // the active object. This makes user LKG fallback, project-level isolation,
884 // and metadata-derived provenance transactional with respect to file changes.
885 var validated Config
886 if _, err := decodeTOMLBytes(data, &validated); err != nil {
887 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
888 }
889 meta, err := decodeTOMLBytes(data, cfg)
890 if err != nil {
891 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
892 }
893 if meta.IsDefined("providers") {
894 var persisted Config
895 if _, err := decodeTOMLBytes(data, &persisted); err != nil {
896 return toml.MetaData{}, fmt.Errorf("config %s: %w", path, err)
897 }
898 markPersistedDeepSeekOfficialPricing(&persisted)
899 markers := map[string]string{}
900 for i := range persisted.Providers {
901 markers[providerMergeKey(persisted.Providers[i])] = persisted.Providers[i].persistedOfficialCurrency
902 }
903 for i := range cfg.Providers {
904 cfg.Providers[i].persistedOfficialCurrency = markers[providerMergeKey(cfg.Providers[i])]
905 }
906 }
907 return meta, nil
908 }
909
910 func mergeRuntimeTOMLFileSnapshot(cfg *Config, path string) (toml.MetaData, error) {
911 if _, err := os.Stat(path); err == nil {
912 if err := migrateLegacyMCPTiersFile(path); err != nil {
913 slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
914 }
915 }
916 return mergeFileSnapshot(cfg, path)
917 }
918
919 // normalizeLegacyMCPTiers keeps loaded legacy config files on the new product
920 // behavior: enabled MCP servers connect in the background by default, and the
921 // retired per-server startup tier is no longer a user-facing setting.
922 func normalizeLegacyMCPTiers(c *Config) {
923 if c == nil {
924 return
925 }
926 for i := range c.Plugins {
927 c.Plugins[i].Tier = ""
928 }
929 }
930
931 // normalizeLegacyAgentStepLimits keeps old TOML readable without allowing a
932 // stale hidden value to override the adaptive progress policy. The fields stay
933 // in AgentConfig for decoder and cross-version desktop compatibility only.
934 func normalizeLegacyAgentStepLimits(c *Config) bool {
935 if c == nil {
936 return false
937 }
938 found := c.Agent.MaxSteps != 0 || c.Agent.PlannerMaxSteps != 0
939 c.Agent.MaxSteps = 0
940 c.Agent.PlannerMaxSteps = 0
941 return found
942 }
943
944 // MigrateLegacyAgentStepLimitsForRoot removes retired [agent] step-limit keys
945 // from the user and project config selected for root. Boot calls it immediately
946 // before LoadForRoot, so config-only/read-only commands never rewrite files and
947 // the runtime can surface exactly one migration notice.
948 func MigrateLegacyAgentStepLimitsForRoot(root string) (bool, error) {
949 root = resolveRoot(root)
950 paths := make([]string, 0, 2)
951 if userPath := userConfigLoadPath(); userPath != "" {
952 paths = append(paths, userPath)
953 }
954 projectPath := "reasonix.toml"
955 if root != "." {
956 projectPath = filepath.Join(root, "reasonix.toml")
957 }
958 paths = append(paths, projectPath)
959
960 changedAny := false
961 seen := make(map[string]struct{}, len(paths))
962 for _, path := range paths {
963 clean := filepath.Clean(path)
964 if _, ok := seen[clean]; ok {
965 continue
966 }
967 seen[clean] = struct{}{}
968 changed, err := migrateLegacyAgentStepLimitsFile(path)
969 if err != nil {
970 return changedAny, fmt.Errorf("migrate deprecated agent step limits in %s: %w", path, err)
971 }
972 changedAny = changedAny || changed
973 }
974 return changedAny, nil
975 }
976
977 // migrateLegacyAgentStepLimitsFile removes retired [agent] step-limit keys
978 // before runtime decoding. A process-wide lock makes concurrent desktop tab
979 // builds observe a single migration; the atomic rewrite protects other readers.
980 func migrateLegacyAgentStepLimitsFile(path string) (bool, error) {
981 return migrateRetiredConfigKeysFile(path, stripLegacyAgentStepLimitLines)
982 }
983
984 func stripLegacyAgentStepLimitLines(raw string) (string, bool) {
985 return stripTOMLKeyLines(raw, "agent", "max_steps", "planner_max_steps")
986 }
987
988 // MigrateLegacyRedactToolOutputForRoot removes the retired
989 // [secrets].redact_tool_output setting from the user and project configs chosen
990 // for root. The setting no longer controls any runtime behavior; removing it
991 // avoids leaving an explicit `true` value on disk that falsely suggests live
992 // output or transcript redaction is still active.
993 func MigrateLegacyRedactToolOutputForRoot(root string) (bool, error) {
994 root = resolveRoot(root)
995 paths := make([]string, 0, 2)
996 if userPath := userConfigLoadPath(); userPath != "" {
997 paths = append(paths, userPath)
998 }
999 projectPath := "reasonix.toml"
1000 if root != "." {
1001 projectPath = filepath.Join(root, "reasonix.toml")
1002 }
1003 paths = append(paths, projectPath)
1004
1005 changedAny := false
1006 seen := make(map[string]struct{}, len(paths))
1007 for _, path := range paths {
1008 clean := filepath.Clean(path)
1009 if _, ok := seen[clean]; ok {
1010 continue
1011 }
1012 seen[clean] = struct{}{}
1013 changed, err := migrateLegacyRedactToolOutputFile(path)
1014 if err != nil {
1015 return changedAny, fmt.Errorf("migrate deprecated redact_tool_output in %s: %w", path, err)
1016 }
1017 changedAny = changedAny || changed
1018 }
1019 return changedAny, nil
1020 }
1021
1022 func migrateLegacyRedactToolOutputFile(path string) (bool, error) {
1023 return migrateRetiredConfigKeysFile(path, stripLegacyRedactToolOutputLines)
1024 }
1025
1026 func stripLegacyRedactToolOutputLines(raw string) (string, bool) {
1027 return stripTOMLKeyLines(raw, "secrets", "redact_tool_output")
1028 }
1029
1030 // MigrateLegacyMemoryCompilerForRoot removes the retired
1031 // [agent].memory_compiler setting from the user and project configs chosen for
1032 // root. The Memory v5 execution compiler was removed; stripping the key avoids
1033 // leaving values on disk that falsely suggest compiler behavior (especially a
1034 // stale verbosity = "compact") is still active.
1035 func MigrateLegacyMemoryCompilerForRoot(root string) (bool, error) {
1036 root = resolveRoot(root)
1037 paths := make([]string, 0, 2)
1038 if userPath := userConfigLoadPath(); userPath != "" {
1039 paths = append(paths, userPath)
1040 }
1041 projectPath := "reasonix.toml"
1042 if root != "." {
1043 projectPath = filepath.Join(root, "reasonix.toml")
1044 }
1045 paths = append(paths, projectPath)
1046
1047 changedAny := false
1048 seen := make(map[string]struct{}, len(paths))
1049 for _, path := range paths {
1050 clean := filepath.Clean(path)
1051 if _, ok := seen[clean]; ok {
1052 continue
1053 }
1054 seen[clean] = struct{}{}
1055 changed, err := migrateLegacyMemoryCompilerFile(path)
1056 if err != nil {
1057 return changedAny, fmt.Errorf("migrate deprecated memory_compiler in %s: %w", path, err)
1058 }
1059 changedAny = changedAny || changed
1060 }
1061 return changedAny, nil
1062 }
1063
1064 func migrateLegacyMemoryCompilerFile(path string) (bool, error) {
1065 return migrateRetiredConfigKeysFile(path, stripLegacyMemoryCompilerLines)
1066 }
1067
1068 func migrateRetiredConfigKeysFile(path string, strip func(string) (string, bool)) (bool, error) {
1069 unlock, err := LockConfigFileEdits(path)
1070 if err != nil {
1071 return false, err
1072 }
1073 defer unlock()
1074 resolved, exists, err := statConfigPath(path)
1075 if err != nil {
1076 return false, err
1077 }
1078 if !exists {
1079 return false, nil
1080 }
1081 info, err := os.Stat(resolved)
1082 if err != nil {
1083 return false, err
1084 }
1085 raw, err := fileencoding.ReadFileUTF8(resolved)
1086 if err != nil {
1087 return false, err
1088 }
1089 next, changed := strip(string(raw))
1090 if !changed {
1091 return false, nil
1092 }
1093 if err := fileutil.AtomicWriteFile(resolved, []byte(next), info.Mode().Perm()); err != nil {
1094 return false, err
1095 }
1096 return true, nil
1097 }
1098
1099 func stripLegacyMemoryCompilerLines(raw string) (string, bool) {
1100 return stripTOMLKeyLines(raw, "agent", "memory_compiler")
1101 }
1102
1103 // MigrateLegacyMultiThresholdCompactionForRoot strips retired soft/snip/force keys.
1104 func MigrateLegacyMultiThresholdCompactionForRoot(root string) (bool, error) {
1105 root = resolveRoot(root)
1106 paths := make([]string, 0, 2)
1107 if userPath := userConfigLoadPath(); userPath != "" {
1108 paths = append(paths, userPath)
1109 }
1110 projectPath := "reasonix.toml"
1111 if root != "." {
1112 projectPath = filepath.Join(root, "reasonix.toml")
1113 }
1114 paths = append(paths, projectPath)
1115
1116 changedAny := false
1117 seen := make(map[string]struct{}, len(paths))
1118 for _, path := range paths {
1119 clean := filepath.Clean(path)
1120 if _, ok := seen[clean]; ok {
1121 continue
1122 }
1123 seen[clean] = struct{}{}
1124 changed, err := migrateLegacyMultiThresholdCompactionFile(path)
1125 if err != nil {
1126 return changedAny, fmt.Errorf("migrate deprecated multi-threshold compaction keys in %s: %w", path, err)
1127 }
1128 changedAny = changedAny || changed
1129 }
1130 return changedAny, nil
1131 }
1132
1133 func migrateLegacyMultiThresholdCompactionFile(path string) (bool, error) {
1134 return migrateRetiredConfigKeysFile(path, stripLegacyMultiThresholdCompactionLines)
1135 }
1136
1137 func stripLegacyMultiThresholdCompactionLines(raw string) (string, bool) {
1138 return stripTOMLKeyLines(raw, "agent",
1139 "soft_compact_ratio",
1140 "tool_result_snip_ratio",
1141 "compact_force_ratio",
1142 "cold_resume_prune",
1143 "context_editing",
1144 )
1145 }
1146
1147 func migrateLegacyMCPTiersFile(path string) error {
1148 _, err := migrateRetiredConfigKeysFile(path, stripLegacyMCPTierLines)
1149 return err
1150 }
1151
1152 // MigrateLegacyMCPTiersForRoot keeps boot's historical on-disk migration
1153 // separate from immutable snapshots, whose freshness checks must be read-only.
1154 func MigrateLegacyMCPTiersForRoot(root string) {
1155 for _, path := range []string{userConfigLoadPath(), filepath.Join(resolveRoot(root), "reasonix.toml")} {
1156 if path == "" {
1157 continue
1158 }
1159 if err := migrateLegacyMCPTiersFile(path); err != nil {
1160 slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
1161 }
1162 }
1163 }
1164
1165 func stripLegacyMCPTierLines(raw string) (string, bool) {
1166 return stripTOMLKeyLines(raw, "plugins", "tier")
1167 }
1168
1169 // tomlStringState tracks whether a line-oriented scan is currently inside a
1170 // TOML multiline string, so retired-key strippers never treat prose inside a
1171 // `"""..."""` or `”'...”'` value (e.g. a config example quoted in a
1172 // system_prompt) as a section header or key assignment.
1173 type tomlStringState int
1174
1175 const (
1176 tomlOutside tomlStringState = iota
1177 tomlInMultilineBasic
1178 tomlInMultilineLiteral
1179 )
1180
1181 // advanceTOMLStringState scans one raw line and returns the multiline-string
1182 // state after it. Outside strings it honours single-line strings and `#`
1183 // comments so quote delimiters inside them cannot open a multiline state.
1184 // The scan is intentionally conservative: on malformed input it prefers
1185 // staying/returning outside, which makes callers keep lines rather than
1186 // delete them.
1187 func advanceTOMLStringState(state tomlStringState, line string) tomlStringState {
1188 i := 0
1189 for i < len(line) {
1190 switch state {
1191 case tomlInMultilineBasic:
1192 if line[i] == '\\' {
1193 i += 2
1194 continue
1195 }
1196 if strings.HasPrefix(line[i:], `"""`) {
1197 state = tomlOutside
1198 i += 3
1199 continue
1200 }
1201 i++
1202 case tomlInMultilineLiteral:
1203 if strings.HasPrefix(line[i:], "'''") {
1204 state = tomlOutside
1205 i += 3
1206 continue
1207 }
1208 i++
1209 default: // tomlOutside
1210 switch {
1211 case line[i] == '#':
1212 return state // rest of the line is a comment
1213 case strings.HasPrefix(line[i:], `"""`):
1214 state = tomlInMultilineBasic
1215 i += 3
1216 case strings.HasPrefix(line[i:], "'''"):
1217 state = tomlInMultilineLiteral
1218 i += 3
1219 case line[i] == '"': // single-line basic string
1220 i++
1221 for i < len(line) && line[i] != '"' {
1222 if line[i] == '\\' {
1223 i++
1224 }
1225 i++
1226 }
1227 i++ // closing quote (or line end on malformed input)
1228 case line[i] == '\'': // single-line literal string
1229 i++
1230 for i < len(line) && line[i] != '\'' {
1231 i++
1232 }
1233 i++
1234 default:
1235 i++
1236 }
1237 }
1238 }
1239 return state
1240 }
1241
1242 // stripTOMLKeyLines removes top-level `key = ...` assignment lines under the
1243 // named section while leaving every line inside a TOML multiline string
1244 // untouched. All retired-config-key migrations share it so none of them can
1245 // corrupt a multiline value (such as a system_prompt quoting a config
1246 // example). A dropped line is first checked to not itself open a multiline
1247 // value; if it would, the line is kept — for these retired keys that never
1248 // happens (their values are single-line), and keeping a stale line is always
1249 // safer than truncating a string the user wrote.
1250 func stripTOMLKeyLines(raw, section string, keys ...string) (string, bool) {
1251 lines := strings.Split(raw, "\n")
1252 current := ""
1253 state := tomlOutside
1254 changed := false
1255 out := make([]string, 0, len(lines))
1256 for _, line := range lines {
1257 if state != tomlOutside {
1258 // Inside a multiline string: never a section header or key line.
1259 out = append(out, line)
1260 state = advanceTOMLStringState(state, line)
1261 continue
1262 }
1263 if header := tomlSectionHeader(line); header != "" {
1264 current = header
1265 }
1266 next := advanceTOMLStringState(tomlOutside, line)
1267 if current == section && next == tomlOutside {
1268 dropped := false
1269 for _, key := range keys {
1270 if isTOMLKeyAssignment(line, key) {
1271 changed = true
1272 dropped = true
1273 break
1274 }
1275 }
1276 if dropped {
1277 continue
1278 }
1279 }
1280 out = append(out, line)
1281 state = next
1282 }
1283 return strings.Join(out, "\n"), changed
1284 }
1285
1286 func tomlSectionHeader(line string) string {
1287 trimmed := strings.TrimSpace(line)
1288 if !strings.HasPrefix(trimmed, "[") {
1289 return ""
1290 }
1291 if i := strings.Index(trimmed, "#"); i >= 0 {
1292 trimmed = strings.TrimSpace(trimmed[:i])
1293 }
1294 if strings.HasPrefix(trimmed, "[[") && strings.HasSuffix(trimmed, "]]") {
1295 return strings.TrimSpace(trimmed[2 : len(trimmed)-2])
1296 }
1297 if strings.HasSuffix(trimmed, "]") {
1298 return strings.TrimSpace(trimmed[1 : len(trimmed)-1])
1299 }
1300 return "other"
1301 }
1302
1303 // normalizeLegacyProviderModels repairs provider entries written by older
1304 // desktop builds that carried the official provider name/endpoint but omitted the
1305 // model field. The repair is intentionally narrow: valid user-provided model
1306 // lists are left untouched, while known official aliases get the model implied by
1307 // their preset name so model pickers and provider validation have an option.
1308 func normalizeLegacyProviderModels(c *Config) {
1309 if c == nil {
1310 return
1311 }
1312 for i := range c.Providers {
1313 p := &c.Providers[i]
1314 if providerHasAnyModel(*p) {
1315 continue
1316 }
1317 if model := legacyOfficialProviderModel(p.Name); model != "" {
1318 p.Model = model
1319 }
1320 }
1321 }
1322
1323 const (
1324 legacyStepFunOpenAIBaseURL = "https://api.stepfun.ai/step_plan/v1"
1325 officialStepFunOpenAIBaseURL = "https://api.stepfun.com/step_plan/v1"
1326 legacyStepFunAnthropicBaseURL = "https://api.stepfun.ai/step_plan"
1327 officialStepFunAnthropicBaseURL = "https://api.stepfun.com/step_plan"
1328 )
1329
1330 func normalizeLegacyStepFunBaseURLs(c *Config) bool {
1331 // Both stepfun.ai (global) and stepfun.com (China) are official endpoints.
1332 // BaseURL is user-owned provider configuration, so neither runtime loading
1333 // nor an unrelated settings save may infer a region and rewrite it.
1334 return false
1335 }
1336
1337 func normalizedBaseURLForMigration(raw string) string {
1338 return strings.TrimRight(strings.TrimSpace(raw), "/")
1339 }
1340
1341 func normalizeLegacyLongCatContextWindows(c *Config) bool {
1342 if c == nil {
1343 return false
1344 }
1345 changed := false
1346 for i := range c.Providers {
1347 p := &c.Providers[i]
1348 if p.ContextWindow != legacyLongCat20ContextWindow {
1349 continue
1350 }
1351 var kind, baseURL string
1352 switch strings.TrimSpace(p.PresetID) {
1353 case "longcat-openai":
1354 kind, baseURL = "openai", longCatOpenAIBaseURL
1355 case "longcat-anthropic":
1356 kind, baseURL = "anthropic", longCatAnthropicBaseURL
1357 default:
1358 continue
1359 }
1360 if !strings.EqualFold(strings.TrimSpace(p.Kind), kind) ||
1361 normalizedBaseURLForMigration(p.BaseURL) != baseURL ||
1362 !stringSlicesEqual(p.Models, longCat20Models) ||
1363 p.Model != "" ||
1364 p.Default != longCat20Models[0] {
1365 continue
1366 }
1367 p.ContextWindow = longCat20ContextWindow
1368 changed = true
1369 }
1370 return changed
1371 }
1372
1373 // normalizeLegacyQwenContextWindows upgrades only installed official Qwen
1374 // presets that still carry the old zero context window and untouched model
1375 // catalog. Custom endpoints, catalogs, provider-wide windows, and existing
1376 // per-model override values remain user-owned.
1377 func normalizeLegacyQwenContextWindows(c *Config) bool {
1378 if c == nil {
1379 return false
1380 }
1381 changed := false
1382 for i := range c.Providers {
1383 p := &c.Providers[i]
1384 if p.ContextWindow != 0 {
1385 continue
1386 }
1387 presetID := qwenPresetIDForMigration(*p)
1388 if presetID == "" {
1389 continue
1390 }
1391 preset, ok := CuratedProviderPreset(presetID)
1392 if !ok || len(preset.Entries) != 1 {
1393 continue
1394 }
1395 canonical := preset.Entries[0]
1396 if !strings.EqualFold(strings.TrimSpace(p.Kind), strings.TrimSpace(canonical.Kind)) ||
1397 normalizedBaseURLForMigration(p.BaseURL) != normalizedBaseURLForMigration(canonical.BaseURL) ||
1398 !stringSlicesEqual(p.Models, canonical.Models) ||
1399 strings.TrimSpace(p.Model) != "" {
1400 continue
1401 }
1402 p.ContextWindow = canonical.ContextWindow
1403 mergeMissingQwenContextOverrides(p, canonical.ModelOverrides)
1404 changed = true
1405 }
1406 return changed
1407 }
1408
1409 func qwenPresetIDForMigration(p ProviderEntry) string {
1410 presetID := strings.TrimSpace(p.PresetID)
1411 if presetID == "" {
1412 presetID = strings.TrimSpace(p.Name)
1413 }
1414 switch presetID {
1415 case "qwen-cn",
1416 "qwen-global",
1417 "qwen-coding-plan-cn",
1418 "qwen-coding-plan-cn-anthropic",
1419 "qwen-coding-plan-global",
1420 "qwen-coding-plan-global-anthropic":
1421 return presetID
1422 default:
1423 return ""
1424 }
1425 }
1426
1427 func mergeMissingQwenContextOverrides(p *ProviderEntry, defaults map[string]ProviderModelOverride) {
1428 if p == nil || len(defaults) == 0 {
1429 return
1430 }
1431 if p.ModelOverrides == nil {
1432 p.ModelOverrides = make(map[string]ProviderModelOverride, len(defaults))
1433 }
1434 for defaultKey, defaultOverride := range defaults {
1435 overrideKey := defaultKey
1436 for key := range p.ModelOverrides {
1437 if strings.EqualFold(strings.TrimSpace(key), defaultKey) {
1438 overrideKey = key
1439 break
1440 }
1441 }
1442 override := p.ModelOverrides[overrideKey]
1443 if override.ContextWindow == 0 {
1444 override.ContextWindow = defaultOverride.ContextWindow
1445 p.ModelOverrides[overrideKey] = override
1446 }
1447 }
1448 }
1449
1450 // normalizeLegacyKimiK3Catalog upgrades only untouched Kimi direct-API model
1451 // catalogs on the official regional endpoints. Custom model lists, endpoints,
1452 // defaults, credentials, and provider-wide settings remain user-owned.
1453 func normalizeLegacyKimiK3Catalog(c *Config) bool {
1454 if c == nil {
1455 return false
1456 }
1457 changed := false
1458 for i := range c.Providers {
1459 p := &c.Providers[i]
1460 presetID := strings.TrimSpace(p.PresetID)
1461 name := strings.TrimSpace(p.Name)
1462 var baseURL string
1463 switch {
1464 case presetID == "kimi-cn" || (presetID == "" && name == "kimi-cn"):
1465 baseURL = "https://api.moonshot.cn/v1"
1466 case presetID == "kimi-global" || (presetID == "" && name == "kimi-global"):
1467 baseURL = "https://api.moonshot.ai/v1"
1468 default:
1469 continue
1470 }
1471 if !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
1472 normalizedBaseURLForMigration(p.BaseURL) != baseURL ||
1473 !stringSlicesEqual(p.Models, legacyKimiAPIModels) ||
1474 strings.TrimSpace(p.Model) != "" {
1475 continue
1476 }
1477 p.Models = append([]string(nil), kimiAPIModels...)
1478 p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, legacyKimiAPIModels)
1479 mergeMissingKimiK3Override(p, kimiK3DirectOverride())
1480 changed = true
1481 }
1482 return changed
1483 }
1484
1485 // migrateKimiK3VisionModels preserves explicit provider-level vision choices.
1486 // A nil list or an exact copy of the old preset list indicates that the user
1487 // has not customized vision support and should receive Kimi K3's capability.
1488 func migrateKimiK3VisionModels(current, legacy []string) []string {
1489 if current != nil && (legacy == nil || !stringSlicesEqual(current, legacy)) {
1490 return current
1491 }
1492 return mergeModelLists([]string{"kimi-k3"}, current)
1493 }
1494
1495 func mergeMissingKimiK3Override(p *ProviderEntry, defaults ProviderModelOverride) {
1496 if p.ModelOverrides == nil {
1497 p.ModelOverrides = map[string]ProviderModelOverride{}
1498 }
1499 overrideKey := "kimi-k3"
1500 for key := range p.ModelOverrides {
1501 if strings.EqualFold(strings.TrimSpace(key), overrideKey) {
1502 overrideKey = key
1503 break
1504 }
1505 }
1506 kimiK3 := p.ModelOverrides[overrideKey]
1507 if strings.TrimSpace(kimiK3.ReasoningProtocol) == "" {
1508 kimiK3.ReasoningProtocol = defaults.ReasoningProtocol
1509 }
1510 if kimiK3.SupportedEfforts == nil {
1511 kimiK3.SupportedEfforts = append([]string(nil), defaults.SupportedEfforts...)
1512 }
1513 if strings.TrimSpace(kimiK3.DefaultEffort) == "" && containsString(normalizedEffortLevels(kimiK3.SupportedEfforts), defaults.DefaultEffort) {
1514 kimiK3.DefaultEffort = defaults.DefaultEffort
1515 }
1516 if kimiK3.ContextWindow <= 0 {
1517 kimiK3.ContextWindow = defaults.ContextWindow
1518 }
1519 p.ModelOverrides[overrideKey] = kimiK3
1520 }
1521
1522 // normalizeLegacyOpenCodeGoKimiK3Catalog upgrades only the untouched model
1523 // catalog from the original editable OpenCode Go preset. A user-curated model
1524 // list or custom endpoint is left alone, while other provider edits (headers,
1525 // key env, provider-wide context) survive the additive K3 capability update.
1526 func normalizeLegacyOpenCodeGoKimiK3Catalog(c *Config) (changed bool) {
1527 if c == nil {
1528 return false
1529 }
1530 for i := range c.Providers {
1531 p := &c.Providers[i]
1532 presetID := strings.TrimSpace(p.PresetID)
1533 if (presetID != "opencode-go" && (presetID != "" || strings.TrimSpace(p.Name) != "opencode-go")) ||
1534 !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
1535 normalizedBaseURLForMigration(p.BaseURL) != "https://opencode.ai/zen/go/v1" ||
1536 !stringSlicesEqual(p.Models, legacyOpenCodeGoModels) ||
1537 strings.TrimSpace(p.Model) != "" {
1538 continue
1539 }
1540 p.Models = append([]string(nil), opencodeGoModels...)
1541 p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, nil)
1542 mergeMissingKimiK3Override(p, ProviderModelOverride{
1543 ReasoningProtocol: ReasoningProtocolOpenAI,
1544 SupportedEfforts: []string{"high", "max"},
1545 DefaultEffort: "max",
1546 ContextWindow: 1_048_576,
1547 })
1548 changed = true
1549 }
1550 return changed
1551 }
1552
1553 // normalizeLegacyOpenCodeGoVisionCatalog upgrades only the untouched Chat
1554 // catalog that predates OpenCode Go's DeepSeek vision SKU. Custom model lists
1555 // and explicit image-input choices remain user-owned.
1556 func normalizeLegacyOpenCodeGoVisionCatalog(c *Config) (changed bool) {
1557 if c == nil {
1558 return false
1559 }
1560 for i := range c.Providers {
1561 p := &c.Providers[i]
1562 presetID := strings.TrimSpace(p.PresetID)
1563 if (presetID != "opencode-go" && (presetID != "" || strings.TrimSpace(p.Name) != "opencode-go")) ||
1564 !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
1565 normalizedBaseURLForMigration(p.BaseURL) != "https://opencode.ai/zen/go/v1" ||
1566 !stringSlicesEqual(p.Models, preVisionOpenCodeGoModels) ||
1567 strings.TrimSpace(p.Model) != "" {
1568 continue
1569 }
1570 p.Models = append([]string(nil), opencodeGoModels...)
1571 if p.VisionModels == nil || stringSlicesEqual(p.VisionModels, preVisionOpenCodeGoVisionModels) {
1572 p.VisionModels = append([]string(nil), opencodeGoVisionModels...)
1573 }
1574 mergeMissingOpenCodeGoVisionOverride(p)
1575 changed = true
1576 }
1577 return changed
1578 }
1579
1580 func mergeMissingOpenCodeGoVisionOverride(p *ProviderEntry) {
1581 if p.ModelOverrides == nil {
1582 p.ModelOverrides = map[string]ProviderModelOverride{}
1583 }
1584 const model = "deepseek-v4-flash-vision-exp"
1585 key := model
1586 for candidate := range p.ModelOverrides {
1587 if strings.EqualFold(strings.TrimSpace(candidate), model) {
1588 key = candidate
1589 break
1590 }
1591 }
1592 override := p.ModelOverrides[key]
1593 if strings.TrimSpace(override.ReasoningProtocol) == "" {
1594 override.ReasoningProtocol = ReasoningProtocolDeepSeek
1595 }
1596 if override.SupportedEfforts == nil {
1597 override.SupportedEfforts = []string{"disabled", "low", "high", "max"}
1598 }
1599 if strings.TrimSpace(override.DefaultEffort) == "" && containsString(normalizedEffortLevels(override.SupportedEfforts), "high") {
1600 override.DefaultEffort = "high"
1601 }
1602 if override.ContextWindow <= 0 {
1603 override.ContextWindow = 1_000_000
1604 }
1605 p.ModelOverrides[key] = override
1606 }
1607
1608 func normalizeLegacyMimoProviderCatalogs(c *Config) bool {
1609 if c == nil {
1610 return false
1611 }
1612 changed := false
1613 for i := range c.Providers {
1614 p := &c.Providers[i]
1615 if legacyMimoProviderName(p.Name) == "" || len(p.Models) > 0 {
1616 continue
1617 }
1618 switch officialProviderHost(p.BaseURL) {
1619 case "api.xiaomimimo.com":
1620 if applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{"mimo-v2.5", "mimo-v2-omni"}, "mimo-v2.5-pro") {
1621 changed = true
1622 }
1623 case "token-plan-cn.xiaomimimo.com":
1624 if applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{"mimo-v2.5"}, "mimo-v2.5-pro") {
1625 changed = true
1626 }
1627 }
1628 }
1629 return changed
1630 }
1631
1632 func applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {
1633 if p == nil || len(models) == 0 {
1634 return false
1635 }
1636 beforeModels := append([]string(nil), p.Models...)
1637 beforeVision := append([]string(nil), p.VisionModels...)
1638 beforeDefault := p.Default
1639 beforeModel := p.Model
1640 beforeWindow := p.ContextWindow
1641 beforeNoProxy := p.NoProxy
1642 beforePricesLen := len(p.Prices)
1643
1644 currentDefault := strings.TrimSpace(p.Default)
1645 if currentDefault == "" {
1646 currentDefault = strings.TrimSpace(p.Model)
1647 }
1648 p.Models = mergeModelLists(models, p.ModelList())
1649 p.Model = p.Models[0]
1650 p.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)
1651 p.VisionModels = mergeModelLists(visionModels, p.VisionModels)
1652 backfillOfficialContextWindow(p, 1_048_576)
1653 p.NoProxy = true
1654 if p.Prices == nil {
1655 p.Prices = mimoDomesticPrices(models)
1656 } else {
1657 for model, price := range mimoDomesticPrices(models) {
1658 if p.Prices[model] == nil {
1659 p.Prices[model] = price
1660 }
1661 }
1662 }
1663
1664 return !stringSlicesEqual(beforeModels, p.Models) ||
1665 !stringSlicesEqual(beforeVision, p.VisionModels) ||
1666 beforeDefault != p.Default ||
1667 beforeModel != p.Model ||
1668 beforeWindow != p.ContextWindow ||
1669 beforeNoProxy != p.NoProxy ||
1670 beforePricesLen != len(p.Prices)
1671 }
1672
1673 func stringSlicesEqual(a, b []string) bool {
1674 if len(a) != len(b) {
1675 return false
1676 }
1677 for i := range a {
1678 if a[i] != b[i] {
1679 return false
1680 }
1681 }
1682 return true
1683 }
1684
1685 func normalizeOfficialDeepSeekModels(c *Config) {
1686 if c == nil {
1687 return
1688 }
1689 for i := range c.Providers {
1690 p := &c.Providers[i]
1691 if officialProviderHost(p.BaseURL) != "api.deepseek.com" {
1692 continue
1693 }
1694 switch strings.TrimSpace(p.Name) {
1695 case "deepseek":
1696 ensureProviderModels(p, []string{"deepseek-v4-flash", "deepseek-v4-pro"}, "deepseek-v4-flash")
1697 case "deepseek-flash":
1698 ensureProviderModels(p, []string{"deepseek-v4-flash"}, "deepseek-v4-flash")
1699 case "deepseek-pro":
1700 ensureProviderModels(p, []string{"deepseek-v4-pro"}, "deepseek-v4-pro")
1701 case "deepseek-responses":
1702 ensureProviderModels(p, []string{"deepseek-v4-flash", "deepseek-v4-pro"}, "deepseek-v4-flash")
1703 }
1704 backfillOfficialDeepSeekResponsesModels(p)
1705 backfillDeepSeekAnthropicCapabilities(p)
1706 }
1707 }
1708
1709 func backfillDeepSeekAnthropicCapabilities(p *ProviderEntry) {
1710 if p == nil || !strings.EqualFold(strings.TrimSpace(p.Kind), "anthropic") ||
1711 !IsOfficialDeepSeekWebSearchEndpoint(p) {
1712 return
1713 }
1714 if strings.TrimSpace(p.Thinking) == "" {
1715 p.Thinking = "enabled"
1716 }
1717 }
1718
1719 func officialProviderHost(baseURL string) string {
1720 u, err := url.Parse(strings.TrimSpace(baseURL))
1721 if err != nil {
1722 return ""
1723 }
1724 return strings.ToLower(u.Hostname())
1725 }
1726
1727 func ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {
1728 if p == nil {
1729 return
1730 }
1731 // If the user has explicitly curated a model list (via Settings), respect
1732 // that choice and do not merge additional required models.
1733 if len(p.Models) > 0 {
1734 return
1735 }
1736 models := mergeModelLists(required, p.ModelList())
1737 if len(models) == 0 {
1738 return
1739 }
1740 p.Model = models[0]
1741 if len(models) > 1 {
1742 p.Models = models
1743 p.Default = firstKnownModel(p.Default, models, fallbackDefault)
1744 return
1745 }
1746 p.Models = nil
1747 p.Default = ""
1748 }
1749
1750 func legacyOfficialProviderModel(name string) string {
1751 switch strings.TrimSpace(name) {
1752 case "deepseek-flash":
1753 return "deepseek-v4-flash"
1754 case "deepseek-pro":
1755 return "deepseek-v4-pro"
1756 case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro":
1757 return "mimo-v2.5-pro"
1758 case "mimo-flash":
1759 return "mimo-v2.5"
1760 default:
1761 return ""
1762 }
1763 }
1764
1765 func normalizeLegacyMimoCustomProviders(c *Config) bool {
1766 return normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)
1767 }
1768
1769 // NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible
1770 // MiMo providers needed by legacy refs that live outside reasonix.toml, such as
1771 // restored desktop tab state.
1772 func NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {
1773 return normalizeLegacyMimoCustomProvidersForRefs(c, refs...)
1774 }
1775
1776 func normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {
1777 if c == nil {
1778 return false
1779 }
1780 needed := map[string]bool{}
1781 addRef := func(ref string) {
1782 if name := legacyMimoProviderNameForRef(ref); name != "" {
1783 needed[name] = true
1784 }
1785 }
1786 for _, ref := range refs {
1787 addRef(ref)
1788 }
1789 changed := normalizeLegacyMimoProviderCatalogs(c)
1790 for name := range needed {
1791 if _, ok := c.Provider(name); ok {
1792 continue
1793 }
1794 c.Providers = append(c.Providers, legacyMimoCustomProvider(name))
1795 changed = true
1796 }
1797 if normalizeLegacyMimoProviderCatalogs(c) {
1798 changed = true
1799 }
1800 return changed
1801 }
1802
1803 func legacyMimoConfigRefs(c *Config) []string {
1804 if c == nil {
1805 return nil
1806 }
1807 refs := []string{
1808 c.DefaultModel,
1809 c.Agent.PlannerModel,
1810 c.Agent.VisionModel,
1811 c.Agent.WebSearchModel,
1812 c.Agent.SubagentModel,
1813 c.Bot.Model,
1814 }
1815 for _, ref := range c.Agent.SubagentModels {
1816 refs = append(refs, ref)
1817 }
1818 for _, conn := range c.Bot.Connections {
1819 refs = append(refs, conn.Model)
1820 }
1821 refs = append(refs, c.Desktop.ProviderAccess...)
1822 return refs
1823 }
1824
1825 func legacyMimoProviderName(ref string) string {
1826 switch strings.TrimSpace(ref) {
1827 case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro", "mimo-flash":
1828 return strings.TrimSpace(ref)
1829 default:
1830 return ""
1831 }
1832 }
1833
1834 func legacyMimoProviderNameForRef(ref string) string {
1835 ref = strings.TrimSpace(ref)
1836 if ref == "" {
1837 return ""
1838 }
1839 providerName, _, hasModel := strings.Cut(ref, "/")
1840 if name := legacyMimoProviderName(providerName); name != "" {
1841 return name
1842 }
1843 if hasModel {
1844 return ""
1845 }
1846 switch ref {
1847 case "mimo-v2.5-pro":
1848 return "mimo-pro"
1849 case "mimo-v2.5":
1850 return "mimo-flash"
1851 case "mimo-v2-omni":
1852 return "mimo-api"
1853 default:
1854 return ""
1855 }
1856 }
1857
1858 func legacyMimoAPIModels() []string {
1859 return []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}
1860 }
1861
1862 func legacyMimoTokenPlanModels() []string {
1863 return []string{"mimo-v2.5-pro", "mimo-v2.5"}
1864 }
1865
1866 func legacyMimoCustomProvider(name string) ProviderEntry {
1867 switch strings.TrimSpace(name) {
1868 case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api":
1869 models := legacyMimoAPIModels()
1870 return ProviderEntry{
1871 Name: strings.TrimSpace(name),
1872 Kind: "openai",
1873 BaseURL: "https://api.xiaomimimo.com/v1",
1874 Models: models,
1875 VisionModels: []string{"mimo-v2.5", "mimo-v2-omni"},
1876 Default: "mimo-v2.5-pro",
1877 APIKeyEnv: "MIMO_API_KEY",
1878 ContextWindow: 1_048_576,
1879 Prices: mimoDomesticPrices(models),
1880 NoProxy: true,
1881 }
1882 case "mimo-token-plan":
1883 models := legacyMimoTokenPlanModels()
1884 return ProviderEntry{
1885 Name: "mimo-token-plan",
1886 Kind: "openai",
1887 BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
1888 Models: models,
1889 VisionModels: []string{"mimo-v2.5"},
1890 Default: "mimo-v2.5-pro",
1891 APIKeyEnv: "MIMO_API_KEY",
1892 ContextWindow: 1_048_576,
1893 Prices: mimoDomesticPrices(models),
1894 NoProxy: true,
1895 }
1896 case "mimo-flash":
1897 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}
1898 default:
1899 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}
1900 }
1901 }
1902
1903 func normalizeDesktopOfficialProviderAccess(c *Config) {
1904 if c == nil || len(c.Desktop.ProviderAccess) == 0 {
1905 return
1906 }
1907 canCanonicalizeDeepSeek := canCanonicalizeLegacyDeepSeekProviders(c)
1908 _, hasCanonicalDeepSeek := c.Provider("deepseek")
1909 legacyDeepSeek := officialLegacyDeepSeekProviders(c)
1910 seen := desktopProviderAccessMap(nil)
1911 next := make([]string, 0, len(c.Desktop.ProviderAccess))
1912 for _, name := range c.Desktop.ProviderAccess {
1913 name = strings.TrimSpace(name)
1914 if name == "deepseek" && !canCanonicalizeDeepSeek && !hasCanonicalDeepSeek && len(legacyDeepSeek) > 0 {
1915 for _, legacy := range legacyDeepSeek {
1916 if !seen[legacy.Name] {
1917 seen[legacy.Name] = true
1918 next = append(next, legacy.Name)
1919 }
1920 }
1921 continue
1922 }
1923 if CanonicalDesktopOfficialProviderName(name) != "deepseek" || name == "deepseek" || canCanonicalizeDeepSeek {
1924 name = desktopProviderAccessNameForConfig(c, name)
1925 }
1926 if name == "" || seen[name] {
1927 continue
1928 }
1929 seen[name] = true
1930 next = append(next, name)
1931 }
1932 c.Desktop.ProviderAccess = next
1933 if seen["deepseek"] {
1934 ensureDeepSeekOfficialProvider(c)
1935 }
1936 normalizeLegacyMimoProviderCatalogs(c)
1937 retargetAccess := maps.Clone(seen)
1938 if p, ok := c.Provider("deepseek"); !canCanonicalizeDeepSeek || !ok || officialProviderKind(p) != "deepseek" {
1939 delete(retargetAccess, "deepseek")
1940 }
1941 retargetDesktopOfficialRefs(c, retargetAccess)
1942 }
1943
1944 // NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list
1945 // for configs written before Settings tracked explicit provider access. Callers
1946 // should only use this when they know the TOML did not declare provider_access;
1947 // an explicit empty list means the user removed all access entries.
1948 func NormalizeLegacyDesktopProviderAccess(c *Config) {
1949 if c == nil || len(c.Desktop.ProviderAccess) > 0 {
1950 return
1951 }
1952 seen := desktopProviderAccessMap(nil)
1953 var access []string
1954 add := func(name string) {
1955 name = desktopProviderAccessNameForConfig(c, name)
1956 if name == "" || seen[name] {
1957 return
1958 }
1959 seen[name] = true
1960 access = append(access, name)
1961 }
1962 addRef := func(ref string) {
1963 if entry, ok := c.ResolveModel(ref); ok {
1964 if !entry.Configured() {
1965 return
1966 }
1967 add(entry.Name)
1968 }
1969 }
1970 addRef(c.DefaultModel)
1971 addRef(c.Agent.PlannerModel)
1972 addRef(c.Agent.VisionModel)
1973 addRef(c.Agent.WebSearchModel)
1974 addRef(c.Agent.SubagentModel)
1975 for _, ref := range c.Agent.SubagentModels {
1976 addRef(ref)
1977 }
1978 addRef(c.Bot.Model)
1979 for _, conn := range c.Bot.Connections {
1980 addRef(conn.Model)
1981 }
1982 for i := range c.Providers {
1983 p := &c.Providers[i]
1984 if legacyMimoProviderName(p.Name) != "" && len(p.ModelList()) > 0 {
1985 add(p.Name)
1986 continue
1987 }
1988 if p.Configured() && len(p.ModelList()) > 0 {
1989 add(p.Name)
1990 }
1991 }
1992 if len(access) == 0 {
1993 return
1994 }
1995 c.Desktop.ProviderAccess = access
1996 normalizeDesktopOfficialProviderAccess(c)
1997 }
1998
1999 func canonicalDesktopOfficialProviderName(name string) string {
2000 switch strings.TrimSpace(name) {
2001 case "deepseek-flash", "deepseek-pro":
2002 return "deepseek"
2003 default:
2004 return strings.TrimSpace(name)
2005 }
2006 }
2007
2008 func desktopProviderAccessNameForConfig(c *Config, name string) string {
2009 name = strings.TrimSpace(name)
2010 if name == "" {
2011 return ""
2012 }
2013 canonical := canonicalDesktopOfficialProviderName(name)
2014 if canonical == name {
2015 return name
2016 }
2017 if c == nil {
2018 return canonical
2019 }
2020 if p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {
2021 return name
2022 }
2023 return canonical
2024 }
2025
2026 func providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {
2027 if p == nil {
2028 return false
2029 }
2030 switch canonical {
2031 case "deepseek":
2032 return isCanonicalizableLegacyDeepSeekProvider(p)
2033 default:
2034 return false
2035 }
2036 }
2037
2038 // CanonicalDesktopOfficialProviderName returns the Settings Center provider ID
2039 // for built-in official provider aliases.
2040 func CanonicalDesktopOfficialProviderName(name string) string {
2041 return canonicalDesktopOfficialProviderName(name)
2042 }
2043
2044 func desktopProviderAccessMap(names []string) map[string]bool {
2045 out := map[string]bool{}
2046 for _, name := range names {
2047 name = strings.TrimSpace(name)
2048 if name != "" {
2049 out[name] = true
2050 }
2051 }
2052 return out
2053 }
2054
2055 func ensureDeepSeekOfficialProvider(c *Config) {
2056 if p, ok := c.Provider("deepseek"); ok {
2057 if officialProviderKind(p) == "deepseek" {
2058 backfillOfficialContextWindow(p, 1_000_000)
2059 }
2060 return
2061 }
2062 if !canCanonicalizeLegacyDeepSeekProviders(c) {
2063 return
2064 }
2065 entry := ProviderEntry{
2066 Name: "deepseek",
2067 Kind: "anthropic",
2068 BaseURL: deepSeekAnthropicBaseURL,
2069 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
2070 Default: "deepseek-v4-flash",
2071 APIKeyEnv: "DEEPSEEK_API_KEY",
2072 BalanceURL: "https://api.deepseek.com/user/balance",
2073 Thinking: "enabled",
2074 WebSearch: boolPointer(true),
2075 ContextWindow: 1_000_000,
2076 Prices: deepSeekV4PricesForConfig(c),
2077 }
2078 legacyProviders := officialLegacyDeepSeekProviders(c)
2079 if len(legacyProviders) > 0 {
2080 entry = officialProviderFromLegacy(entry, legacyProviders[0])
2081 currency := c.DeepSeekOfficialPricingCurrency()
2082 if c.DesktopCurrency() == "" && legacyProviders[0].persistedOfficialCurrency != "" {
2083 currency = legacyProviders[0].persistedOfficialCurrency
2084 entry.persistedOfficialCurrency = currency
2085 }
2086 entry.Prices = DeepSeekV4PricesForCurrency(currency)
2087 for _, old := range legacyProviders {
2088 entry.Models = mergeModelLists(entry.Models, old.ModelList())
2089 mergeLegacyDeepSeekModelConfiguration(&entry, old)
2090 }
2091 entry.Default = preferredLegacyDeepSeekDefault(legacyProviders, entry.Models, entry.Default)
2092 }
2093 backfillOfficialContextWindow(&entry, 1_000_000)
2094 c.Providers = append(c.Providers, entry)
2095 }
2096
2097 func isOpenAIProviderKind(e *ProviderEntry) bool {
2098 return e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), "openai")
2099 }
2100
2101 func mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {
2102 // If the user has explicitly curated a model list (via Settings), respect
2103 // that choice and do not merge additional curated models.
2104 if len(e.Models) > 0 {
2105 return
2106 }
2107 currentDefault := e.Default
2108 if strings.TrimSpace(currentDefault) == "" {
2109 currentDefault = e.Model
2110 }
2111 e.Models = mergeModelLists(models, e.ModelList())
2112 e.Default = firstKnownModel(currentDefault, e.Models, fallback)
2113 }
2114
2115 func backfillOfficialContextWindow(e *ProviderEntry, fallback int) {
2116 if e != nil && e.ContextWindow <= 0 {
2117 e.ContextWindow = fallback
2118 }
2119 }
2120
2121 func officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {
2122 if old == nil {
2123 return entry
2124 }
2125 // Start from the legacy entry so current and future transport fields are not
2126 // silently dropped from the effective canonical provider. Identity, catalog,
2127 // pricing and capability fields are merged model by model below.
2128 legacy := cloneProviderEntry(*old)
2129 legacy.Name = entry.Name
2130 legacy.Model = ""
2131 legacy.Models = append([]string(nil), entry.Models...)
2132 legacy.Default = entry.Default
2133 legacy.ContextWindow = entry.ContextWindow
2134 legacy.MaxOutputTokens = entry.MaxOutputTokens
2135 legacy.Price = nil
2136 legacy.Prices = clonePricingMap(entry.Prices)
2137 legacy.ReasoningProtocol = entry.ReasoningProtocol
2138 legacy.SupportedEfforts = append([]string(nil), entry.SupportedEfforts...)
2139 legacy.DefaultEffort = entry.DefaultEffort
2140 legacy.Vision = entry.Vision
2141 legacy.VisionModels = append([]string(nil), entry.VisionModels...)
2142 legacy.ModelOverrides = cloneModelOverrideMap(entry.ModelOverrides)
2143 return legacy
2144 }
2145
2146 func officialLegacyDeepSeekProviders(c *Config) []*ProviderEntry {
2147 if c == nil {
2148 return nil
2149 }
2150 out := make([]*ProviderEntry, 0, 2)
2151 for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
2152 if p, ok := c.Provider(name); ok && isCanonicalizableLegacyDeepSeekProvider(p) {
2153 out = append(out, p)
2154 }
2155 }
2156 return out
2157 }
2158
2159 func isCanonicalizableLegacyDeepSeekProvider(p *ProviderEntry) bool {
2160 if p == nil {
2161 return false
2162 }
2163 switch strings.ToLower(strings.TrimSpace(p.Kind)) {
2164 case "openai":
2165 return isOfficialDeepSeekOpenAIEndpoint(p.BaseURL)
2166 case "anthropic":
2167 return IsOfficialDeepSeekWebSearchEndpoint(p)
2168 default:
2169 return false
2170 }
2171 }
2172
2173 func canCanonicalizeLegacyDeepSeekProviders(c *Config) bool {
2174 if c == nil {
2175 return true
2176 }
2177 legacy := officialLegacyDeepSeekProviders(c)
2178 if canonical, ok := c.Provider("deepseek"); ok {
2179 if officialProviderKind(canonical) != "deepseek" {
2180 return false
2181 }
2182 for _, old := range legacy {
2183 if !legacyDeepSeekProviderWideFieldsEqual(canonical, old) ||
2184 !legacyDeepSeekModelFieldsCompatibleIgnoringDefault(canonical, old) {
2185 return false
2186 }
2187 }
2188 }
2189 for i := 1; i < len(legacy); i++ {
2190 if !legacyDeepSeekProviderWideFieldsEqual(legacy[0], legacy[i]) ||
2191 !legacyDeepSeekModelFieldsCompatible(legacy[0], legacy[i]) {
2192 return false
2193 }
2194 }
2195 return true
2196 }
2197
2198 func legacyDeepSeekModelFieldsCompatibleIgnoringDefault(a, b *ProviderEntry) bool {
2199 if a == nil || b == nil {
2200 return a == b
2201 }
2202 left := cloneProviderEntry(*a)
2203 right := cloneProviderEntry(*b)
2204 left.Default = ""
2205 right.Default = ""
2206 return legacyDeepSeekModelFieldsCompatible(&left, &right)
2207 }
2208
2209 func legacyDeepSeekProviderWideFieldsEqual(a, b *ProviderEntry) bool {
2210 if a == nil || b == nil {
2211 return a == b
2212 }
2213 left := legacyDeepSeekProviderWideProjection(a)
2214 right := legacyDeepSeekProviderWideProjection(b)
2215 return reflect.DeepEqual(left, right)
2216 }
2217
2218 func legacyDeepSeekProviderWideProjection(entry *ProviderEntry) ProviderEntry {
2219 out := cloneProviderEntry(*entry)
2220 out.Name = ""
2221 out.Kind = strings.ToLower(strings.TrimSpace(out.Kind))
2222 out.BaseURL = normalizedBaseURLForMigration(out.BaseURL)
2223 out.ChatURL = strings.TrimSpace(out.ChatURL)
2224 out.RequestURL = strings.TrimSpace(out.RequestURL)
2225 out.ModelsURL = strings.TrimSpace(out.ModelsURL)
2226 out.APIKeyEnv = strings.TrimSpace(out.APIKeyEnv)
2227 out.BalanceURL = normalizedDeepSeekBalanceURL(out.BalanceURL)
2228 out.ResponsesMode = strings.TrimSpace(out.ResponsesMode)
2229 out.Thinking = strings.TrimSpace(out.Thinking)
2230 out.Effort = strings.TrimSpace(out.Effort)
2231 out.VisionDetail = strings.TrimSpace(out.VisionDetail)
2232
2233 // These fields can be represented independently for every model in the
2234 // canonical provider. They are compared by legacyDeepSeekModelFieldsCompatible.
2235 out.Model = ""
2236 out.Models = nil
2237 out.Default = ""
2238 out.ContextWindow = 0
2239 out.MaxOutputTokens = 0
2240 out.Price = nil
2241 out.Prices = nil
2242 out.ReasoningProtocol = ""
2243 out.SupportedEfforts = nil
2244 out.DefaultEffort = ""
2245 out.Vision = false
2246 out.VisionModels = nil
2247 out.ModelOverrides = nil
2248 out.visionOverride = nil
2249 out.resolvedAPIKey = ""
2250 out.resolvedSource = CredentialSource{}
2251 return out
2252 }
2253
2254 func normalizedDeepSeekBalanceURL(raw string) string {
2255 raw = strings.TrimRight(strings.TrimSpace(raw), "/")
2256 if raw == "" {
2257 return deepSeekOfficialBalanceURL
2258 }
2259 return raw
2260 }
2261
2262 type legacyDeepSeekModelFields struct {
2263 contextWindowSet bool
2264 contextWindow int
2265 maxOutputTokensSet bool
2266 maxOutputTokens int
2267 priceSet bool
2268 price *provider.Pricing
2269 reasoningProtocolSet bool
2270 reasoningProtocol string
2271 supportedEffortsSet bool
2272 supportedEfforts []string
2273 defaultEffortSet bool
2274 defaultEffort string
2275 visionSet bool
2276 vision bool
2277 }
2278
2279 func legacyDeepSeekModelFieldsCompatible(a, b *ProviderEntry) bool {
2280 if a == nil || b == nil {
2281 return a == b
2282 }
2283 if left, right := strings.TrimSpace(a.Default), strings.TrimSpace(b.Default); left != "" && right != "" && left != right {
2284 return false
2285 }
2286 models := map[string]string{}
2287 add := func(model string) {
2288 model = strings.TrimSpace(model)
2289 if model != "" {
2290 models[strings.ToLower(model)] = model
2291 }
2292 }
2293 for _, entry := range []*ProviderEntry{a, b} {
2294 for _, model := range entry.ModelList() {
2295 add(model)
2296 }
2297 for _, model := range entry.VisionModels {
2298 add(model)
2299 }
2300 for model := range entry.Prices {
2301 add(model)
2302 }
2303 for model := range entry.ModelOverrides {
2304 add(model)
2305 }
2306 }
2307 for _, model := range models {
2308 left, leftSet := legacyDeepSeekModelFieldProjection(a, model)
2309 right, rightSet := legacyDeepSeekModelFieldProjection(b, model)
2310 if leftSet && rightSet && !legacyDeepSeekModelFieldProjectionsCompatible(left, right) {
2311 return false
2312 }
2313 }
2314 return true
2315 }
2316
2317 func legacyDeepSeekModelFieldProjection(entry *ProviderEntry, model string) (legacyDeepSeekModelFields, bool) {
2318 var out legacyDeepSeekModelFields
2319 if entry == nil {
2320 return out, false
2321 }
2322 listed := entry.HasModel(model)
2323 if listed {
2324 out.contextWindowSet = true
2325 out.contextWindow = entry.ContextWindow
2326 if out.contextWindow <= 0 {
2327 out.contextWindow = 1_000_000
2328 }
2329 out.maxOutputTokensSet = true
2330 out.maxOutputTokens = entry.MaxOutputTokens
2331 out.reasoningProtocolSet = true
2332 out.reasoningProtocol = strings.TrimSpace(entry.ReasoningProtocol)
2333 out.supportedEffortsSet = true
2334 out.supportedEfforts = append([]string(nil), entry.SupportedEfforts...)
2335 out.defaultEffortSet = true
2336 out.defaultEffort = strings.TrimSpace(entry.DefaultEffort)
2337 out.visionSet = true
2338 out.vision = entry.Vision || entry.HasVisionModel(model)
2339 if price := entry.PriceForModel(model); price != nil {
2340 out.priceSet = true
2341 out.price = price
2342 }
2343 }
2344 if price, ok := pricingForModelKey(entry.Prices, model); ok {
2345 out.priceSet = true
2346 out.price = clonePricing(price)
2347 }
2348 if override, ok := entry.modelOverrideForModel(model); ok {
2349 if override.ContextWindow > 0 {
2350 out.contextWindowSet = true
2351 out.contextWindow = override.ContextWindow
2352 }
2353 if override.MaxOutputTokens != 0 {
2354 out.maxOutputTokensSet = true
2355 out.maxOutputTokens = override.MaxOutputTokens
2356 }
2357 if strings.TrimSpace(override.ReasoningProtocol) != "" {
2358 out.reasoningProtocolSet = true
2359 out.reasoningProtocol = strings.TrimSpace(override.ReasoningProtocol)
2360 }
2361 if override.SupportedEfforts != nil {
2362 out.supportedEffortsSet = true
2363 out.supportedEfforts = append([]string(nil), override.SupportedEfforts...)
2364 out.defaultEffortSet = true
2365 out.defaultEffort = strings.TrimSpace(override.DefaultEffort)
2366 }
2367 if override.Vision != nil {
2368 out.visionSet = true
2369 out.vision = *override.Vision
2370 }
2371 }
2372 return out, listed || out.contextWindowSet || out.maxOutputTokensSet || out.priceSet ||
2373 out.reasoningProtocolSet || out.supportedEffortsSet || out.defaultEffortSet || out.visionSet
2374 }
2375
2376 func pricingForModelKey(prices map[string]*provider.Pricing, model string) (*provider.Pricing, bool) {
2377 for key, price := range prices {
2378 if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(model)) {
2379 return price, true
2380 }
2381 }
2382 return nil, false
2383 }
2384
2385 func legacyDeepSeekModelFieldProjectionsCompatible(a, b legacyDeepSeekModelFields) bool {
2386 return (!a.contextWindowSet || !b.contextWindowSet || a.contextWindow == b.contextWindow) &&
2387 (!a.maxOutputTokensSet || !b.maxOutputTokensSet || a.maxOutputTokens == b.maxOutputTokens) &&
2388 (!a.priceSet || !b.priceSet || reflect.DeepEqual(a.price, b.price)) &&
2389 (!a.reasoningProtocolSet || !b.reasoningProtocolSet || a.reasoningProtocol == b.reasoningProtocol) &&
2390 (!a.supportedEffortsSet || !b.supportedEffortsSet || slices.Equal(a.supportedEfforts, b.supportedEfforts)) &&
2391 (!a.defaultEffortSet || !b.defaultEffortSet || a.defaultEffort == b.defaultEffort) &&
2392 (!a.visionSet || !b.visionSet || a.vision == b.vision)
2393 }
2394
2395 func preferredLegacyDeepSeekDefault(entries []*ProviderEntry, models []string, fallback string) string {
2396 for _, entry := range entries {
2397 if entry == nil {
2398 continue
2399 }
2400 candidate := strings.TrimSpace(entry.Default)
2401 if candidate != "" && slices.Contains(models, candidate) {
2402 return candidate
2403 }
2404 }
2405 return firstKnownModel(fallback, models, "deepseek-v4-flash")
2406 }
2407
2408 func mergeLegacyDeepSeekModelConfiguration(entry, old *ProviderEntry) {
2409 if entry == nil || old == nil {
2410 return
2411 }
2412 if entry.Prices == nil {
2413 entry.Prices = map[string]*provider.Pricing{}
2414 }
2415 if entry.ModelOverrides == nil {
2416 entry.ModelOverrides = map[string]ProviderModelOverride{}
2417 }
2418 entry.VisionModels = mergeModelLists(entry.VisionModels, old.VisionModels)
2419 for model, price := range old.Prices {
2420 entry.Prices[model] = clonePricing(price)
2421 }
2422 for _, model := range old.ModelList() {
2423 model = strings.TrimSpace(model)
2424 if model == "" {
2425 continue
2426 }
2427 if price := old.PriceForModel(model); price != nil {
2428 entry.Prices[model] = price
2429 }
2430 override := entry.ModelOverrides[model]
2431 if old.ContextWindow > 0 && old.ContextWindow != entry.ContextWindow {
2432 override.ContextWindow = old.ContextWindow
2433 }
2434 if old.MaxOutputTokens != entry.MaxOutputTokens {
2435 override.MaxOutputTokens = old.MaxOutputTokens
2436 }
2437 if protocol := strings.TrimSpace(old.ReasoningProtocol); protocol != "" {
2438 override.ReasoningProtocol = protocol
2439 }
2440 if len(old.SupportedEfforts) > 0 {
2441 override.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)
2442 override.DefaultEffort = old.DefaultEffort
2443 }
2444 if old.Vision || old.HasVisionModel(model) {
2445 vision := true
2446 override.Vision = &vision
2447 }
2448 if explicit, ok := old.modelOverrideForModel(model); ok {
2449 mergeProviderModelOverride(&override, explicit)
2450 }
2451 entry.ModelOverrides[model] = override
2452 }
2453 for model, override := range old.ModelOverrides {
2454 if old.HasModel(model) {
2455 continue
2456 }
2457 current := entry.ModelOverrides[model]
2458 mergeProviderModelOverride(&current, override)
2459 entry.ModelOverrides[model] = current
2460 }
2461 }
2462
2463 func mergeProviderModelOverride(dst *ProviderModelOverride, src ProviderModelOverride) {
2464 if dst == nil {
2465 return
2466 }
2467 if strings.TrimSpace(src.ReasoningProtocol) != "" {
2468 dst.ReasoningProtocol = src.ReasoningProtocol
2469 }
2470 if len(src.SupportedEfforts) > 0 {
2471 dst.SupportedEfforts = append([]string(nil), src.SupportedEfforts...)
2472 dst.DefaultEffort = src.DefaultEffort
2473 }
2474 if src.Vision != nil {
2475 vision := *src.Vision
2476 dst.Vision = &vision
2477 }
2478 if src.ContextWindow > 0 {
2479 dst.ContextWindow = src.ContextWindow
2480 }
2481 if src.MaxOutputTokens != 0 {
2482 dst.MaxOutputTokens = src.MaxOutputTokens
2483 }
2484 }
2485
2486 func mergeModelLists(primary, extra []string) []string {
2487 seen := map[string]bool{}
2488 out := make([]string, 0, len(primary))
2489 for _, list := range [][]string{primary, extra} {
2490 for _, model := range list {
2491 model = strings.TrimSpace(model)
2492 if model == "" || seen[model] {
2493 continue
2494 }
2495 seen[model] = true
2496 out = append(out, model)
2497 }
2498 }
2499 return out
2500 }
2501
2502 func firstKnownModel(current string, models []string, fallback string) string {
2503 current = strings.TrimSpace(current)
2504 if slices.Contains(models, current) {
2505 return current
2506 }
2507 if slices.Contains(models, fallback) {
2508 return fallback
2509 }
2510 if len(models) > 0 {
2511 return models[0]
2512 }
2513 return ""
2514 }
2515
2516 func retargetDesktopOfficialRefs(c *Config, access map[string]bool) {
2517 c.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)
2518 c.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)
2519 c.Agent.VisionModel = retargetDesktopOfficialRef(c.Agent.VisionModel, access)
2520 c.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)
2521 for skill, ref := range c.Agent.SubagentModels {
2522 c.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)
2523 }
2524 }
2525
2526 func retargetDesktopOfficialRef(ref string, access map[string]bool) string {
2527 ref = strings.TrimSpace(ref)
2528 if ref == "" {
2529 return ""
2530 }
2531 provider, model, hasModel := strings.Cut(ref, "/")
2532 switch provider {
2533 case "deepseek-flash":
2534 if !access["deepseek"] {
2535 return ref
2536 }
2537 if !hasModel || strings.TrimSpace(model) == "" {
2538 model = "deepseek-v4-flash"
2539 }
2540 return "deepseek/" + model
2541 case "deepseek-pro":
2542 if !access["deepseek"] {
2543 return ref
2544 }
2545 if !hasModel || strings.TrimSpace(model) == "" {
2546 model = "deepseek-v4-pro"
2547 }
2548 return "deepseek/" + model
2549 default:
2550 return ref
2551 }
2552 }
2553
2553 lines GO