1280 lines
37 KiB
Go
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
|
|
}
|
|
}
|