| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/BurntSushi/toml" |
| 14 | ) |
| 15 | |
| 16 | func NewModelSettingsOfferID() (string, error) { |
| 17 | var id [16]byte |
| 18 | if _, err := rand.Read(id[:]); err != nil { |
| 19 | return "", err |
| 20 | } |
| 21 | return hex.EncodeToString(id[:]), nil |
| 22 | } |
| 23 | |
| 24 | // ModelRuntimeSettings is an in-memory model resolver bundle. Desktop sends |
| 25 | // only tunnel tokens in Credentials; actual provider credentials remain local. |
| 26 | // It is never serialized into session history or a system prompt. |
| 27 | type ModelRuntimeSettings struct { |
| 28 | SourceToken string `json:"sourceToken,omitempty"` |
| 29 | OfferID string `json:"offerID,omitempty"` |
| 30 | ProxyURL string `json:"proxyURL"` |
| 31 | Revision string `json:"revision"` |
| 32 | Providers []ProviderEntry `json:"providers"` |
| 33 | Credentials map[string]string `json:"credentials"` |
| 34 | References map[string]string `json:"references"` |
| 35 | Preferences ModelRuntimePreferences `json:"preferences"` |
| 36 | } |
| 37 | |
| 38 | // ModelSettingsSourceRequest is an authenticated, transient tunnel exchange. |
| 39 | // Offer ownership protects candidate routes until Serve publishes or rejects |
| 40 | // them. IDs are random correlation values and never enter user configuration. |
| 41 | type ModelSettingsSourceRequest struct { |
| 42 | ModelSettingsOwnership |
| 43 | Mode string `json:"mode"` |
| 44 | OfferID string `json:"offerID"` |
| 45 | PreviousOfferID string `json:"previousOfferID,omitempty"` |
| 46 | Model string `json:"model,omitempty"` |
| 47 | AppliedRevision string `json:"appliedRevision,omitempty"` |
| 48 | RemotePort int `json:"remotePort,omitempty"` |
| 49 | OwnedRevisions []string `json:"ownedRevisions"` |
| 50 | UnversionedOwners bool `json:"unversionedOwners"` |
| 51 | } |
| 52 | |
| 53 | // ModelSettingsOwnership orders complete owner snapshots within one Serve. |
| 54 | type ModelSettingsOwnership struct { |
| 55 | OwnershipIncarnation string `json:"ownershipIncarnation"` |
| 56 | OwnershipSeq uint64 `json:"ownershipSeq"` |
| 57 | } |
| 58 | |
| 59 | type ModelSettingsSourceResponse struct { |
| 60 | Version int `json:"version"` |
| 61 | Revision string `json:"revision"` |
| 62 | Ref string `json:"ref,omitempty"` |
| 63 | Settings *ModelRuntimeSettings `json:"settings,omitempty"` |
| 64 | } |
| 65 | |
| 66 | type ModelRuntimePreferences struct { |
| 67 | PlannerModel string `json:"plannerModel" toml:"planner_model"` |
| 68 | VisionModel string `json:"visionModel" toml:"vision_model"` |
| 69 | WebSearchModel string `json:"webSearchModel" toml:"web_search_model"` |
| 70 | GuardianModel string `json:"guardianModel" toml:"guardian_model"` |
| 71 | RecoveryModel string `json:"recoveryModel" toml:"recovery_model"` |
| 72 | SubagentModel string `json:"subagentModel" toml:"subagent_model"` |
| 73 | SubagentModels map[string]string `json:"subagentModels" toml:"subagent_models"` |
| 74 | SubagentEffort string `json:"subagentEffort" toml:"subagent_effort"` |
| 75 | SubagentEfforts map[string]string `json:"subagentEfforts" toml:"subagent_efforts"` |
| 76 | MaxSubagentDepth int `json:"maxSubagentDepth" toml:"max_subagent_depth"` |
| 77 | MaxSubagentConcurrency int `json:"maxSubagentConcurrency" toml:"max_subagent_concurrency"` |
| 78 | MaxParallelWriters int `json:"maxParallelWriters" toml:"max_parallel_writers"` |
| 79 | } |
| 80 | |
| 81 | func (c *Config) RuntimeModelPreferences() ModelRuntimePreferences { |
| 82 | var out ModelRuntimePreferences |
| 83 | src, dst := reflect.ValueOf(c.Agent), reflect.ValueOf(&out).Elem() |
| 84 | for i := range dst.NumField() { |
| 85 | dst.Field(i).Set(src.FieldByName(dst.Type().Field(i).Name)) |
| 86 | } |
| 87 | return out |
| 88 | } |
| 89 | |
| 90 | // Apply overlays the desktop-managed resolver, retaining explicitly configured |
| 91 | // project models/preferences. The loaded Config is private to this boot; clone |
| 92 | // the input first so concurrent boot builders never share mutable maps. |
| 93 | func (settings *ModelRuntimeSettings) Apply(c *Config, root string) error { |
| 94 | if settings == nil { |
| 95 | return nil |
| 96 | } |
| 97 | raw, err := json.Marshal(settings) |
| 98 | if err != nil { |
| 99 | return fmt.Errorf("encode model snapshot: %w", err) |
| 100 | } |
| 101 | var frozen ModelRuntimeSettings |
| 102 | if err := json.Unmarshal(raw, &frozen); err != nil { |
| 103 | return err |
| 104 | } |
| 105 | var project map[string]any |
| 106 | projectRaw, err := os.ReadFile(filepath.Join(root, "reasonix.toml")) |
| 107 | if err != nil && !os.IsNotExist(err) { |
| 108 | return err |
| 109 | } |
| 110 | if len(projectRaw) > 0 { |
| 111 | if _, err := toml.Decode(string(projectRaw), &project); err != nil { |
| 112 | return err |
| 113 | } |
| 114 | } |
| 115 | projectProviders := map[string]bool{} |
| 116 | if entries, ok := project["providers"].([]map[string]any); ok { |
| 117 | for _, entry := range entries { |
| 118 | if name, ok := entry["name"].(string); ok { |
| 119 | projectProviders[name] = true |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | retained := make([]ProviderEntry, 0, len(projectProviders)) |
| 124 | projectEntries := map[string]ProviderEntry{} |
| 125 | for _, p := range c.Providers { |
| 126 | if projectProviders[p.Name] { |
| 127 | retained = append(retained, p) |
| 128 | projectEntries[p.Name] = p |
| 129 | } |
| 130 | } |
| 131 | // The Desktop expands multi-model providers into separately credentialed |
| 132 | // routes. Preserve project overrides under those transport aliases too. |
| 133 | sourceNames := map[string]string{} |
| 134 | for source, target := range frozen.References { |
| 135 | sourceName, _, _ := strings.Cut(source, "/") |
| 136 | targetName, _, _ := strings.Cut(target, "/") |
| 137 | sourceNames[targetName] = sourceName |
| 138 | } |
| 139 | c.Providers = nil |
| 140 | seen := map[string]bool{} |
| 141 | for _, p := range frozen.Providers { |
| 142 | if strings.TrimSpace(p.Name) == "" || seen[p.Name] { |
| 143 | return fmt.Errorf("model snapshot has an empty or duplicate provider") |
| 144 | } |
| 145 | seen[p.Name] = true |
| 146 | if projectEntry, ok := projectEntries[sourceNames[p.Name]]; ok { |
| 147 | alias := projectEntry |
| 148 | alias.Name = p.Name |
| 149 | c.Providers = append(c.Providers, alias) |
| 150 | continue |
| 151 | } |
| 152 | if projectProviders[p.Name] { |
| 153 | continue |
| 154 | } |
| 155 | p.resolvedAPIKey, p.credentialsFrozen = frozen.Credentials[p.Name], true |
| 156 | p.credentialProxyURL = frozen.ProxyURL |
| 157 | c.Providers = append(c.Providers, p) |
| 158 | } |
| 159 | c.Providers = append(c.Providers, retained...) |
| 160 | c.Desktop.ProviderAccess = nil |
| 161 | for _, p := range c.Providers { |
| 162 | c.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, p.Name) |
| 163 | } |
| 164 | declared, _ := project["agent"].(map[string]any) |
| 165 | src, dst := reflect.ValueOf(frozen.Preferences), reflect.ValueOf(&c.Agent).Elem() |
| 166 | for i := range src.NumField() { |
| 167 | field := src.Type().Field(i) |
| 168 | if _, explicit := declared[field.Tag.Get("toml")]; !explicit { |
| 169 | dst.FieldByName(field.Name).Set(src.Field(i)) |
| 170 | } |
| 171 | } |
| 172 | // Explicit project assignments still use the user's provider names. Route |
| 173 | // them to the managed alias only when the project has no provider override. |
| 174 | mapRef := func(ref string) string { |
| 175 | name, _, _ := strings.Cut(ref, "/") |
| 176 | if mapped := frozen.References[ref]; mapped != "" && !projectProviders[name] { |
| 177 | return mapped |
| 178 | } |
| 179 | return ref |
| 180 | } |
| 181 | for _, ref := range []*string{&c.Agent.PlannerModel, &c.Agent.VisionModel, &c.Agent.WebSearchModel, &c.Agent.GuardianModel, &c.Agent.RecoveryModel, &c.Agent.SubagentModel} { |
| 182 | *ref = mapRef(*ref) |
| 183 | } |
| 184 | for name, ref := range c.Agent.SubagentModels { |
| 185 | c.Agent.SubagentModels[name] = mapRef(ref) |
| 186 | } |
| 187 | return nil |
| 188 | } |
| 189 | |
| 190 | func (e *ProviderEntry) CredentialProxyURL() string { return e.credentialProxyURL } |
| 191 |