Files
2026-07-13 13:00:08 +08:00

1280 lines
37 KiB
Go

package config
import (
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/provider"
)
// Load builds the configuration: defaults, then user config, then project
// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)
// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values
// resolve from Reasonix's global .env, not from project .env files.
func Load() (*Config, error) {
return LoadForRoot(".")
}
// LoadForRoot builds the configuration with project files resolved from root
// instead of the current working directory. When root is "" or ".", it behaves
// like Load(). This is the workspace-aware entry point: desktop tabs use it so
// each project's reasonix.toml + .mcp.json are resolved independently without
// changing the process cwd, while provider keys stay rooted in Reasonix home.
//
// Note: LoadForRoot may rewrite legacy MCP `tier` lines on disk (see
// mergeRuntimeTOMLFile). Callers that must not mutate config files should use
// LoadForRootReadOnly instead.
func LoadForRoot(root string) (*Config, error) {
return loadForRoot(root, true)
}
// LoadForRootReadOnly is like LoadForRoot but never writes config files: it skips
// on-disk legacy MCP tier migration. Prefer this for diagnostics, doctor, and
// other read-only inspection paths.
func LoadForRootReadOnly(root string) (*Config, error) {
return loadForRoot(root, false)
}
func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
root = resolveRoot(root)
expansionEnv := loadDotEnvForRoot(root)
cfg := Default()
cfg.setExpansionEnv(expansionEnv)
cfg.CredentialsStore = credentialsStoreMode()
projectTOML := "reasonix.toml"
if root != "." {
projectTOML = filepath.Join(root, "reasonix.toml")
}
mergeTOML := mergeFile
if migrateOnDisk {
mergeTOML = mergeRuntimeTOMLFile
}
var tomlSources []string
userDefaultModelExplicit := false
if uc := userConfigLoadPath(); uc != "" {
tomlSources = append(tomlSources, uc)
if err := mergeTOML(cfg, uc); err != nil {
return nil, err
}
userDefaultModelExplicit = tomlFileDefinesKey(uc, "default_model")
}
globalMaxSteps := cfg.Agent.MaxSteps
globalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps
globalMemoryCompiler := cfg.Agent.MemoryCompiler
userDefaultModel := cfg.DefaultModel
// Deep-copy: TOML decoding writes through an existing *bool rather than
// replacing it, so a shallow struct copy would alias the pointer and the
// project merge below would mutate the captured global value in place.
globalSecrets := cfg.Secrets
if cfg.Secrets.RedactToolOutput != nil {
v := *cfg.Secrets.RedactToolOutput
globalSecrets.RedactToolOutput = &v
}
tomlSources = append(tomlSources, projectTOML)
if err := mergeTOML(cfg, projectTOML); err != nil {
return nil, err
}
// Runtime step caps are user/global controls, not project policy. Keep the
// project config's other fields, but do not let ./reasonix.toml override
// the user's execution and planner round limits.
cfg.Agent.MaxSteps = globalMaxSteps
cfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps
cfg.Agent.MemoryCompiler = globalMemoryCompiler
// Secret protection is a user-global security control: a cloned repo's
// reasonix.toml must not be able to disable redaction or flip on the
// workflow-breaking env/path protections.
cfg.Secrets = globalSecrets
// TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds
// only the last file's. Re-merge by name across all sources (later wins) so a
// project reasonix.toml doesn't drop the global config's MCP servers.
// mergeTOMLPlugins only reads files; it does not run on-disk migrations.
plugins, err := mergeTOMLPlugins(tomlSources)
if err != nil {
return nil, err
}
cfg.Plugins = plugins
if providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {
return nil, err
} else if ok {
cfg.Providers = providers
cfg.providerSources = providerSources
cfg.shadowedProjectProviders = shadowedProjectProviders
}
if access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {
return nil, err
} else if ok {
cfg.Desktop.ProviderAccess = access
}
// Claude Code's .mcp.json (project root) is read last and merged into
// [[plugins]], so a server configured for Claude works here unchanged.
// reasonix.toml wins on a name collision (see mergeMCPJSON).
mcpFile := mcpJSONFile
if root != "." {
mcpFile = filepath.Join(root, mcpJSONFile)
}
entries, err := loadMCPJSON(mcpFile)
if err != nil {
return nil, err
}
cfg.mergeMCPJSON(entries)
// Lowest priority before the one-time v1.9.1 MCP migration: the v0.x
// ~/.reasonix/config.json's mcpServers. Once the migration marker exists, the
// current config is authoritative even when it is empty; reading the legacy
// source again would resurrect servers the user removed from current config.
if !mcpGlobalMigrationComplete() {
cfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))
}
_ = mergeInstalledPluginPackages(cfg, root)
normalizePluginCommandLines(cfg)
normalizeLegacyEffort(cfg)
normalizeLegacyMCPTiers(cfg)
normalizeLegacyStepFunBaseURLs(cfg)
normalizeLegacyMimoCustomProviders(cfg)
normalizeLegacyProviderModels(cfg)
normalizeDesktopOfficialProviderAccess(cfg)
normalizeOfficialDeepSeekModels(cfg)
applyDeepSeekOfficialDefaultPricing(cfg)
backfillDeepSeekOfficialPrices(cfg)
normalizeEffortConfig(cfg)
backfillDeepSeekPro(cfg)
if userDefaultModelExplicit {
restoreUnresolvableProjectDefaultModel(cfg, userDefaultModel)
}
cfg.Agent.AutoPlan = userAutoPlanMode()
cfg.CredentialsStore = credentialsStoreMode()
cfg.setExpansionEnv(expansionEnv)
resolveProviderCredentialsForRoot(root, cfg)
return cfg, nil
}
func (c *Config) setExpansionEnv(env map[string]string) {
if c == nil {
return
}
c.expansionEnv = cloneStringMap(env)
for i := range c.Plugins {
c.Plugins[i].expansionEnv = c.expansionEnv
}
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func userAutoPlanMode() string {
cfg := Default()
if uc := userConfigLoadPath(); uc != "" {
_ = mergeFile(cfg, uc)
}
switch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {
case "on", "ask":
return "on"
default:
return "off"
}
}
// restoreUnresolvableProjectDefaultModel falls back to the user/global
// default_model when a project reasonix.toml overrides it with a reference no
// configured provider serves (#4218). Pre-v1.11 persistence paths (e.g. the
// "always allow" writer) full-rendered ./reasonix.toml and pinned the built-in
// default_model ("deepseek-flash") into it; once the user's [[providers]]
// replaced the built-in presets, that stale name resolved to nothing and boot
// hard-failed in every launch from that folder. In-memory only — the project
// file is untouched, and a project override that does resolve still wins. The
// ignored value is kept so boot can surface a notice.
//
// Callers must only invoke this when the user config explicitly defines
// default_model: falling back to the built-in default would silently mask a
// broken ref when the project file is the user's only config, and that case
// must keep the actionable boot error (TestBuildUnknownModelErrorIsActionable).
func restoreUnresolvableProjectDefaultModel(c *Config, userDefault string) {
if c == nil {
return
}
if c.DefaultModel == userDefault {
return
}
if _, ok := c.ResolveModel(c.DefaultModel); ok {
return
}
if _, ok := c.ResolveModel(userDefault); !ok {
return
}
c.ignoredProjectDefaultModel = c.DefaultModel
c.DefaultModel = userDefault
}
// tomlFileDefinesKey reports whether the TOML file at path explicitly defines
// the given top-level key. Missing or unparseable files report false.
func tomlFileDefinesKey(path string, key ...string) bool {
var f Config
meta, err := decodeTOMLFile(path, &f)
if err != nil {
return false
}
return meta.IsDefined(key...)
}
// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard
// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro
// SKU, leaving users unable to switch to it. In-memory only — the user's file is
// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to
// serve pro) so a custom flash-only deployment isn't given an entry that 404s.
func backfillDeepSeekPro(c *Config) {
const flashModel, proModel = "deepseek-v4-flash", "deepseek-v4-pro"
var flash *ProviderEntry
for i := range c.Providers {
p := &c.Providers[i]
if p.Name == "deepseek-pro" {
return
}
for _, m := range p.ModelList() {
switch m {
case proModel:
return // pro already reachable
case flashModel:
if strings.Contains(p.BaseURL, "api.deepseek.com") {
flash = p
}
}
}
}
if flash == nil {
return
}
// If the user has explicitly curated a model list for the flash provider
// (e.g. unchecked pro in Settings), respect that choice and do not backfill.
if len(flash.Models) > 0 {
return
}
for _, bp := range Default().Providers {
if bp.Name == "deepseek-pro" {
bp.APIKeyEnv = flash.APIKeyEnv
bp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)
c.Providers = append(c.Providers, bp)
return
}
}
}
func backfillDeepSeekOfficialPrices(c *Config) {
if c == nil {
return
}
defaults := deepSeekV4PricesForConfig(c)
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderKind(p) != "deepseek" {
continue
}
if p.Price != nil {
continue
}
if p.Prices == nil {
p.Prices = map[string]*provider.Pricing{}
}
for model, price := range defaults {
if p.HasModel(model) && p.Prices[model] == nil {
p.Prices[model] = clonePricing(price)
}
}
}
}
func officialProviderKind(p *ProviderEntry) string {
if p == nil {
return ""
}
u, err := url.Parse(strings.TrimSpace(p.BaseURL))
if err != nil {
return ""
}
if strings.EqualFold(u.Hostname(), "api.deepseek.com") {
return "deepseek"
}
return ""
}
func resolveRoot(root string) string {
if root == "" || root == "." {
return "."
}
return filepath.Clean(root)
}
// normalizeLegacyEffort migrates the retired DeepSeek effort="off" (the old
// /thinking off that disabled thinking) to the provider default, so a config
// written by an older version keeps loading instead of erroring on a value the
// provider no longer accepts.
func normalizeLegacyEffort(c *Config) {
for i := range c.Providers {
if strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), "off") {
c.Providers[i].Effort = ""
}
}
}
// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).
func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {
var merged []PluginEntry
index := map[string]int{}
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
var f Config
if _, err := decodeTOMLFile(path, &f); err != nil {
return nil, fmt.Errorf("config %s: %w", path, err)
}
for _, p := range f.Plugins {
p, _ = NormalizePluginCommandLine(p)
if i, ok := index[p.Name]; ok {
merged[i] = p
continue
}
index[p.Name] = len(merged)
merged = append(merged, p)
}
}
return merged, nil
}
// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.
// User-global providers win over same-named project providers; project providers
// only fill names the global config does not define. Keep official legacy aliases
// distinct here: they can carry different default models and effort capabilities,
// and the later desktop normalization layer handles canonical Settings access.
func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {
var merged []ProviderEntry
var shadowedProject []ProviderEntry
index := map[string]int{}
sources := map[string]providerSourceScope{}
saw := false
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
var f Config
if _, err := decodeTOMLFile(path, &f); err != nil {
return nil, nil, nil, false, fmt.Errorf("config %s: %w", path, err)
}
if len(f.Providers) == 0 {
continue
}
saw = true
source := providerSourceForPath(path)
for _, p := range f.Providers {
normalizeProviderEffortFields(&p)
key := providerMergeKey(p)
if i, ok := index[key]; ok {
if sources[key] == providerSourceProject && source == providerSourceUser {
shadowedProject = append(shadowedProject, merged[i])
merged[i] = p
sources[key] = source
} else if sources[key] == providerSourceUser && source == providerSourceProject {
shadowedProject = append(shadowedProject, p)
}
continue
} else {
index[key] = len(merged)
merged = append(merged, p)
sources[key] = source
}
}
}
return merged, sources, shadowedProject, saw, nil
}
func providerSourceForPath(path string) providerSourceScope {
if isUserConfigPath(path) {
return providerSourceUser
}
return providerSourceProject
}
func providerMergeKey(p ProviderEntry) string {
return strings.TrimSpace(p.Name)
}
// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so
// project desktop settings do not hide account-level providers from the desktop
// model switcher.
func mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {
var merged []string
seen := map[string]bool{}
saw := false
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
continue
}
var f Config
meta, err := decodeTOMLFile(path, &f)
if err != nil {
return nil, false, fmt.Errorf("config %s: %w", path, err)
}
if !meta.IsDefined("desktop", "provider_access") {
continue
}
saw = true
for _, name := range f.Desktop.ProviderAccess {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
merged = append(merged, name)
}
}
return merged, saw, nil
}
// ConfigFileDeclarations contains provider settings explicitly declared by one
// TOML file, without defaults or values inherited from another scope.
type ConfigFileDeclarations struct {
ProviderNames []string
DesktopProviderAccessDeclared bool
}
// InspectConfigFileDeclarations returns the provider-related fields explicitly
// present in one TOML file. It deliberately does not include built-in defaults
// or values inherited from another config scope.
func InspectConfigFileDeclarations(path string) (ConfigFileDeclarations, error) {
var declarations ConfigFileDeclarations
path = strings.TrimSpace(path)
if path == "" {
return declarations, nil
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return declarations, nil
}
return declarations, err
}
var f Config
meta, err := decodeTOMLFile(path, &f)
if err != nil {
return declarations, fmt.Errorf("config %s: %w", path, err)
}
seen := make(map[string]bool, len(f.Providers))
for _, provider := range f.Providers {
name := strings.TrimSpace(provider.Name)
if name == "" || seen[name] {
continue
}
seen[name] = true
declarations.ProviderNames = append(declarations.ProviderNames, name)
}
declarations.DesktopProviderAccessDeclared = meta.IsDefined("desktop", "provider_access")
return declarations, nil
}
// DesktopProviderAccessDeclared reports whether path explicitly declares
// desktop.provider_access. It distinguishes omission from an intentional [].
func DesktopProviderAccessDeclared(path string) (bool, error) {
declarations, err := InspectConfigFileDeclarations(path)
return declarations.DesktopProviderAccessDeclared, err
}
// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:
// the built-in defaults with the file at path (if present) decoded on top, so a
// reconfigure preserves the user's existing providers and agent settings instead
// of resetting to defaults. Reasonix's global .env is loaded so api_key_env
// resolution works while the wizard decides which keys are still missing.
func LoadForEdit(path string) *Config {
return loadForEdit(path, true, true)
}
// LoadForEditReadOnlyStrict is the error-returning commit-time variant. It must
// not fall back to defaults when another writer leaves malformed TOML, because
// saving that fallback would overwrite the user's recoverable file.
func LoadForEditReadOnlyStrict(path string) (*Config, error) {
return loadForEditStrict(path, true, false)
}
func loadForEdit(path string, loadCredentials, persistMigrations bool) *Config {
cfg, err := loadForEditStrict(path, loadCredentials, persistMigrations)
if err == nil {
return cfg
}
slog.Warn("config: load for edit failed, using defaults", "path", path, "err", err)
if loadCredentials {
loadDotEnvForEditPath(path)
}
cfg = Default()
normalizeConfigForEdit(cfg)
return cfg
}
func LoadForEditWithoutCredentials(path string) *Config {
return loadForEdit(path, false, true)
}
func loadForEditStrict(path string, loadCredentials, persistMigrations bool) (*Config, error) {
if loadCredentials {
loadDotEnvForEditPath(path)
}
cfg := Default()
if persistMigrations {
if _, err := os.Stat(path); err == nil {
if err := migrateLegacyMCPTiersFile(path); err != nil {
return nil, fmt.Errorf("config %s: %w", path, err)
}
}
}
if err := mergeFile(cfg, path); err != nil {
return nil, err
}
changed := normalizeConfigForEdit(cfg)
if persistMigrations && changed && strings.TrimSpace(path) != "" {
if _, err := os.Stat(path); err == nil {
if err := cfg.SaveTo(path); err != nil {
return nil, err
}
}
}
return cfg, nil
}
func normalizeConfigForEdit(cfg *Config) bool {
normalizePluginCommandLines(cfg)
normalizeLegacyEffort(cfg)
normalizeLegacyMCPTiers(cfg)
changed := normalizeLegacyStepFunBaseURLs(cfg)
changed = normalizeLegacyMimoCustomProviders(cfg) || changed
normalizeLegacyProviderModels(cfg)
normalizeDesktopOfficialProviderAccess(cfg)
applyDeepSeekOfficialDefaultPricing(cfg)
backfillDeepSeekOfficialPrices(cfg)
normalizeEffortConfig(cfg)
return changed
}
func loadDotEnvForEditPath(path string) {
path = strings.TrimSpace(path)
if path == "" || isUserConfigPath(path) {
loadDotEnv()
return
}
loadDotEnvForRoot(filepath.Dir(path))
}
// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.
func mergeFile(cfg *Config, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
if _, err := decodeTOMLFile(path, cfg); err != nil {
return fmt.Errorf("config %s: %w", path, err)
}
return nil
}
func mergeRuntimeTOMLFile(cfg *Config, path string) error {
if _, err := os.Stat(path); err == nil {
if err := migrateLegacyMCPTiersFile(path); err != nil {
slog.Warn("config: legacy mcp tier migration failed", "path", path, "err", err)
}
}
return mergeFile(cfg, path)
}
// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product
// behavior: enabled MCP servers connect in the background by default, and the
// retired per-server startup tier is no longer a user-facing setting.
func normalizeLegacyMCPTiers(c *Config) {
if c == nil {
return
}
for i := range c.Plugins {
c.Plugins[i].Tier = ""
}
}
func migrateLegacyMCPTiersFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
raw, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return err
}
next, changed := stripLegacyMCPTierLines(string(raw))
if !changed {
return nil
}
return os.WriteFile(path, []byte(next), info.Mode().Perm())
}
func stripLegacyMCPTierLines(raw string) (string, bool) {
lines := strings.Split(raw, "\n")
section := ""
changed := false
out := make([]string, 0, len(lines))
for _, line := range lines {
if header := tomlSectionHeader(line); header != "" {
section = header
}
if section == "plugins" && isTOMLKeyAssignment(line, "tier") {
changed = true
continue
}
out = append(out, line)
}
return strings.Join(out, "\n"), changed
}
func tomlSectionHeader(line string) string {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "[") {
return ""
}
if i := strings.Index(trimmed, "#"); i >= 0 {
trimmed = strings.TrimSpace(trimmed[:i])
}
switch trimmed {
case "[[plugins]]":
return "plugins"
default:
return "other"
}
}
func isTOMLKeyAssignment(line, key string) bool {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || !strings.HasPrefix(trimmed, key) {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))
return strings.HasPrefix(rest, "=")
}
// normalizeLegacyProviderModels repairs provider entries written by older
// desktop builds that carried the official provider name/endpoint but omitted the
// model field. The repair is intentionally narrow: valid user-provided model
// lists are left untouched, while known official aliases get the model implied by
// their preset name so model pickers and provider validation have an option.
func normalizeLegacyProviderModels(c *Config) {
if c == nil {
return
}
for i := range c.Providers {
p := &c.Providers[i]
if providerHasAnyModel(*p) {
continue
}
if model := legacyOfficialProviderModel(p.Name); model != "" {
p.Model = model
}
}
}
const (
legacyStepFunOpenAIBaseURL = "https://api.stepfun.ai/step_plan/v1"
officialStepFunOpenAIBaseURL = "https://api.stepfun.com/step_plan/v1"
legacyStepFunAnthropicBaseURL = "https://api.stepfun.ai/step_plan"
officialStepFunAnthropicBaseURL = "https://api.stepfun.com/step_plan"
)
func normalizeLegacyStepFunBaseURLs(c *Config) bool {
if c == nil {
return false
}
changed := false
for i := range c.Providers {
p := &c.Providers[i]
switch {
case isLegacyStepFunPresetProvider(*p, "stepfun", "openai") && normalizedBaseURLForMigration(p.BaseURL) == legacyStepFunOpenAIBaseURL:
p.BaseURL = officialStepFunOpenAIBaseURL
changed = true
case isLegacyStepFunPresetProvider(*p, "stepfun-anthropic", "anthropic") && normalizedBaseURLForMigration(p.BaseURL) == legacyStepFunAnthropicBaseURL:
p.BaseURL = officialStepFunAnthropicBaseURL
changed = true
}
}
return changed
}
func isLegacyStepFunPresetProvider(p ProviderEntry, id, kind string) bool {
if !strings.EqualFold(strings.TrimSpace(p.Kind), kind) {
return false
}
return strings.TrimSpace(p.Name) == id || strings.TrimSpace(p.PresetID) == id
}
func normalizedBaseURLForMigration(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
func normalizeLegacyMimoProviderCatalogs(c *Config) bool {
if c == nil {
return false
}
changed := false
for i := range c.Providers {
p := &c.Providers[i]
if legacyMimoProviderName(p.Name) == "" || len(p.Models) > 0 {
continue
}
switch officialProviderHost(p.BaseURL) {
case "api.xiaomimimo.com":
if applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{"mimo-v2.5", "mimo-v2-omni"}, "mimo-v2.5-pro") {
changed = true
}
case "token-plan-cn.xiaomimimo.com":
if applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{"mimo-v2.5"}, "mimo-v2.5-pro") {
changed = true
}
}
}
return changed
}
func applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {
if p == nil || len(models) == 0 {
return false
}
beforeModels := append([]string(nil), p.Models...)
beforeVision := append([]string(nil), p.VisionModels...)
beforeDefault := p.Default
beforeModel := p.Model
beforeWindow := p.ContextWindow
beforeNoProxy := p.NoProxy
beforePricesLen := len(p.Prices)
currentDefault := strings.TrimSpace(p.Default)
if currentDefault == "" {
currentDefault = strings.TrimSpace(p.Model)
}
p.Models = mergeModelLists(models, p.ModelList())
p.Model = p.Models[0]
p.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)
p.VisionModels = mergeModelLists(visionModels, p.VisionModels)
backfillOfficialContextWindow(p, 1_048_576)
p.NoProxy = true
if p.Prices == nil {
p.Prices = mimoDomesticPrices(models)
} else {
for model, price := range mimoDomesticPrices(models) {
if p.Prices[model] == nil {
p.Prices[model] = price
}
}
}
return !stringSlicesEqual(beforeModels, p.Models) ||
!stringSlicesEqual(beforeVision, p.VisionModels) ||
beforeDefault != p.Default ||
beforeModel != p.Model ||
beforeWindow != p.ContextWindow ||
beforeNoProxy != p.NoProxy ||
beforePricesLen != len(p.Prices)
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func normalizeOfficialDeepSeekModels(c *Config) {
if c == nil {
return
}
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderHost(p.BaseURL) != "api.deepseek.com" {
continue
}
switch strings.TrimSpace(p.Name) {
case "deepseek":
ensureProviderModels(p, []string{"deepseek-v4-flash", "deepseek-v4-pro"}, "deepseek-v4-flash")
case "deepseek-flash":
ensureProviderModels(p, []string{"deepseek-v4-flash"}, "deepseek-v4-flash")
case "deepseek-pro":
ensureProviderModels(p, []string{"deepseek-v4-pro"}, "deepseek-v4-pro")
}
}
}
func officialProviderHost(baseURL string) string {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
return strings.ToLower(u.Hostname())
}
func ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {
if p == nil {
return
}
// If the user has explicitly curated a model list (via Settings), respect
// that choice and do not merge additional required models.
if len(p.Models) > 0 {
return
}
models := mergeModelLists(required, p.ModelList())
if len(models) == 0 {
return
}
p.Model = models[0]
if len(models) > 1 {
p.Models = models
p.Default = firstKnownModel(p.Default, models, fallbackDefault)
return
}
p.Models = nil
p.Default = ""
}
func legacyOfficialProviderModel(name string) string {
switch strings.TrimSpace(name) {
case "deepseek-flash":
return "deepseek-v4-flash"
case "deepseek-pro":
return "deepseek-v4-pro"
case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro":
return "mimo-v2.5-pro"
case "mimo-flash":
return "mimo-v2.5"
default:
return ""
}
}
func normalizeLegacyMimoCustomProviders(c *Config) bool {
return normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)
}
// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible
// MiMo providers needed by legacy refs that live outside reasonix.toml, such as
// restored desktop tab state.
func NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {
return normalizeLegacyMimoCustomProvidersForRefs(c, refs...)
}
func normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {
if c == nil {
return false
}
needed := map[string]bool{}
addRef := func(ref string) {
if name := legacyMimoProviderNameForRef(ref); name != "" {
needed[name] = true
}
}
for _, ref := range refs {
addRef(ref)
}
changed := normalizeLegacyMimoProviderCatalogs(c)
for name := range needed {
if _, ok := c.Provider(name); ok {
continue
}
c.Providers = append(c.Providers, legacyMimoCustomProvider(name))
changed = true
}
if normalizeLegacyMimoProviderCatalogs(c) {
changed = true
}
return changed
}
func legacyMimoConfigRefs(c *Config) []string {
if c == nil {
return nil
}
refs := []string{
c.DefaultModel,
c.Agent.PlannerModel,
c.Agent.SubagentModel,
c.Agent.AutoPlanClassifier,
c.Bot.Model,
}
for _, ref := range c.Agent.SubagentModels {
refs = append(refs, ref)
}
for _, conn := range c.Bot.Connections {
refs = append(refs, conn.Model)
}
refs = append(refs, c.Desktop.ProviderAccess...)
return refs
}
func legacyMimoProviderName(ref string) string {
switch strings.TrimSpace(ref) {
case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro", "mimo-flash":
return strings.TrimSpace(ref)
default:
return ""
}
}
func legacyMimoProviderNameForRef(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
providerName, _, hasModel := strings.Cut(ref, "/")
if name := legacyMimoProviderName(providerName); name != "" {
return name
}
if hasModel {
return ""
}
switch ref {
case "mimo-v2.5-pro":
return "mimo-pro"
case "mimo-v2.5":
return "mimo-flash"
case "mimo-v2-omni":
return "mimo-api"
default:
return ""
}
}
func legacyMimoAPIModels() []string {
return []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}
}
func legacyMimoTokenPlanModels() []string {
return []string{"mimo-v2.5-pro", "mimo-v2.5"}
}
func legacyMimoCustomProvider(name string) ProviderEntry {
switch strings.TrimSpace(name) {
case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api":
models := legacyMimoAPIModels()
return ProviderEntry{
Name: strings.TrimSpace(name),
Kind: "openai",
BaseURL: "https://api.xiaomimimo.com/v1",
Models: models,
VisionModels: []string{"mimo-v2.5", "mimo-v2-omni"},
Default: "mimo-v2.5-pro",
APIKeyEnv: "MIMO_API_KEY",
ContextWindow: 1_048_576,
Prices: mimoDomesticPrices(models),
NoProxy: true,
}
case "mimo-token-plan":
models := legacyMimoTokenPlanModels()
return ProviderEntry{
Name: "mimo-token-plan",
Kind: "openai",
BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
Models: models,
VisionModels: []string{"mimo-v2.5"},
Default: "mimo-v2.5-pro",
APIKeyEnv: "MIMO_API_KEY",
ContextWindow: 1_048_576,
Prices: mimoDomesticPrices(models),
NoProxy: true,
}
case "mimo-flash":
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}
default:
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}
}
}
func normalizeDesktopOfficialProviderAccess(c *Config) {
if c == nil || len(c.Desktop.ProviderAccess) == 0 {
return
}
seen := desktopProviderAccessMap(nil)
next := make([]string, 0, len(c.Desktop.ProviderAccess))
for _, name := range c.Desktop.ProviderAccess {
name = desktopProviderAccessNameForConfig(c, name)
if name == "" || seen[name] {
continue
}
seen[name] = true
next = append(next, name)
}
c.Desktop.ProviderAccess = next
if seen["deepseek"] {
ensureDeepSeekOfficialProvider(c)
}
normalizeLegacyMimoProviderCatalogs(c)
retargetDesktopOfficialRefs(c, seen)
}
// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list
// for configs written before Settings tracked explicit provider access. Callers
// should only use this when they know the TOML did not declare provider_access;
// an explicit empty list means the user removed all access entries.
func NormalizeLegacyDesktopProviderAccess(c *Config) {
if c == nil || len(c.Desktop.ProviderAccess) > 0 {
return
}
seen := desktopProviderAccessMap(nil)
var access []string
add := func(name string) {
name = desktopProviderAccessNameForConfig(c, name)
if name == "" || seen[name] {
return
}
seen[name] = true
access = append(access, name)
}
addRef := func(ref string) {
if entry, ok := c.ResolveModel(ref); ok {
if !entry.Configured() {
return
}
add(entry.Name)
}
}
addRef(c.DefaultModel)
addRef(c.Agent.PlannerModel)
addRef(c.Agent.SubagentModel)
addRef(c.Agent.AutoPlanClassifier)
for _, ref := range c.Agent.SubagentModels {
addRef(ref)
}
addRef(c.Bot.Model)
for _, conn := range c.Bot.Connections {
addRef(conn.Model)
}
for i := range c.Providers {
p := &c.Providers[i]
if legacyMimoProviderName(p.Name) != "" && len(p.ModelList()) > 0 {
add(p.Name)
continue
}
if p.Configured() && len(p.ModelList()) > 0 {
add(p.Name)
}
}
if len(access) == 0 {
return
}
c.Desktop.ProviderAccess = access
normalizeDesktopOfficialProviderAccess(c)
}
func canonicalDesktopOfficialProviderName(name string) string {
switch strings.TrimSpace(name) {
case "deepseek-flash", "deepseek-pro":
return "deepseek"
default:
return strings.TrimSpace(name)
}
}
func desktopProviderAccessNameForConfig(c *Config, name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
canonical := canonicalDesktopOfficialProviderName(name)
if canonical == name {
return name
}
if c == nil {
return canonical
}
if p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {
return name
}
return canonical
}
func providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {
if p == nil {
return false
}
switch canonical {
case "deepseek":
return officialProviderKind(p) == "deepseek"
default:
return false
}
}
// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID
// for built-in official provider aliases.
func CanonicalDesktopOfficialProviderName(name string) string {
return canonicalDesktopOfficialProviderName(name)
}
func desktopProviderAccessMap(names []string) map[string]bool {
out := map[string]bool{}
for _, name := range names {
name = strings.TrimSpace(name)
if name != "" {
out[name] = true
}
}
return out
}
func ensureDeepSeekOfficialProvider(c *Config) {
if p, ok := c.Provider("deepseek"); ok {
if officialProviderKind(p) == "deepseek" {
backfillOfficialContextWindow(p, 1_000_000)
}
return
}
entry := ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
BalanceURL: "https://api.deepseek.com/user/balance",
ContextWindow: 1_000_000,
Prices: deepSeekV4PricesForConfig(c),
}
if old, ok := c.Provider("deepseek-flash"); ok {
entry = officialProviderFromLegacy(entry, old)
entry.Prices = deepSeekV4PricesForConfig(c)
entry.Models = mergeModelLists([]string{"deepseek-v4-flash", "deepseek-v4-pro"}, old.ModelList())
entry.Default = firstKnownModel(entry.Default, entry.Models, "deepseek-v4-flash")
}
backfillOfficialContextWindow(&entry, 1_000_000)
c.Providers = append(c.Providers, entry)
}
func isOpenAIProviderKind(e *ProviderEntry) bool {
return e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), "openai")
}
func mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {
// If the user has explicitly curated a model list (via Settings), respect
// that choice and do not merge additional curated models.
if len(e.Models) > 0 {
return
}
currentDefault := e.Default
if strings.TrimSpace(currentDefault) == "" {
currentDefault = e.Model
}
e.Models = mergeModelLists(models, e.ModelList())
e.Default = firstKnownModel(currentDefault, e.Models, fallback)
}
func backfillOfficialContextWindow(e *ProviderEntry, fallback int) {
if e != nil && e.ContextWindow <= 0 {
e.ContextWindow = fallback
}
}
func officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {
entry.Kind = old.Kind
entry.BaseURL = old.BaseURL
entry.ModelsURL = old.ModelsURL
entry.APIKeyEnv = old.APIKeyEnv
entry.BalanceURL = old.BalanceURL
entry.ContextWindow = old.ContextWindow
entry.Price = old.Price
entry.Thinking = old.Thinking
entry.Effort = old.Effort
entry.ReasoningProtocol = old.ReasoningProtocol
entry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)
entry.DefaultEffort = old.DefaultEffort
entry.NoProxy = old.NoProxy
return entry
}
func mergeModelLists(primary, extra []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(primary)+len(extra))
for _, list := range [][]string{primary, extra} {
for _, model := range list {
model = strings.TrimSpace(model)
if model == "" || seen[model] {
continue
}
seen[model] = true
out = append(out, model)
}
}
return out
}
func firstKnownModel(current string, models []string, fallback string) string {
current = strings.TrimSpace(current)
for _, model := range models {
if model == current {
return current
}
}
for _, model := range models {
if model == fallback {
return fallback
}
}
if len(models) > 0 {
return models[0]
}
return ""
}
func retargetDesktopOfficialRefs(c *Config, access map[string]bool) {
c.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)
c.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)
c.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)
c.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)
for skill, ref := range c.Agent.SubagentModels {
c.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)
}
}
func retargetDesktopOfficialRef(ref string, access map[string]bool) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
provider, model, hasModel := strings.Cut(ref, "/")
switch provider {
case "deepseek-flash":
if !access["deepseek"] {
return ref
}
if !hasModel || strings.TrimSpace(model) == "" {
model = "deepseek-v4-flash"
}
return "deepseek/" + model
case "deepseek-pro":
if !access["deepseek"] {
return ref
}
if !hasModel || strings.TrimSpace(model) == "" {
model = "deepseek-v4-pro"
}
return "deepseek/" + model
default:
return ref
}
}