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