| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/sha256" |
| 6 | "fmt" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/config" |
| 11 | ) |
| 12 | |
| 13 | type providerRemovalPlan struct { |
| 14 | config *config.Config |
| 15 | root string |
| 16 | fingerprint string |
| 17 | targets []string |
| 18 | fallbackRef string |
| 19 | } |
| 20 | |
| 21 | // DeleteProvider commits removal; each runtime resolves its fallback next run. |
| 22 | func (a *App) DeleteProvider(name string) error { |
| 23 | return a.deleteProviderAndRetargetTabs(name) |
| 24 | } |
| 25 | |
| 26 | // RemoveProviderAccess hides one provider access card. The plural form keeps |
| 27 | // official provider profiles represented by a single card failure-atomic. |
| 28 | func (a *App) RemoveProviderAccess(name string) error { |
| 29 | return a.RemoveProviderAccesses([]string{name}) |
| 30 | } |
| 31 | |
| 32 | func (a *App) RemoveProviderAccesses(rawNames []string) error { |
| 33 | names := uniqueNonEmptyStrings(rawNames) |
| 34 | if len(names) == 0 { |
| 35 | return fmt.Errorf("remove provider access: provider list is empty") |
| 36 | } |
| 37 | cfg, _, err := a.loadDesktopUserConfigForView() |
| 38 | if err != nil { |
| 39 | return err |
| 40 | } |
| 41 | if len(names) > 1 && isAtomicCustomProviderGroup(cfg, names) { |
| 42 | return a.deleteProvidersAndRetargetTabs(names) |
| 43 | } |
| 44 | officialKind := "" |
| 45 | for _, name := range names { |
| 46 | p, ok := cfg.Provider(name) |
| 47 | if !ok { |
| 48 | return fmt.Errorf("remove provider access: provider %q not found", name) |
| 49 | } |
| 50 | kind := officialProviderKindFromEntry(*p) |
| 51 | if kind == "" { |
| 52 | if len(names) == 1 { |
| 53 | return a.deleteProviderAndRetargetTabs(name) |
| 54 | } |
| 55 | return fmt.Errorf("remove provider access: custom provider %q cannot be removed as part of a group", name) |
| 56 | } |
| 57 | if officialKind != "" && kind != officialKind { |
| 58 | return fmt.Errorf("remove provider access: providers do not belong to one official group") |
| 59 | } |
| 60 | officialKind = kind |
| 61 | } |
| 62 | return a.removeBuiltInProviderAccessAndRetargetTabs(names) |
| 63 | } |
| 64 | |
| 65 | // isAtomicCustomProviderGroup identifies custom provider families that are |
| 66 | // represented by one settings card but persisted as multiple routes. |
| 67 | // Keep this allowlist narrow: RemoveProviderAccesses intentionally rejects |
| 68 | // arbitrary custom-provider batches so callers cannot accidentally delete |
| 69 | // unrelated endpoints in one operation. |
| 70 | func isAtomicCustomProviderGroup(c *config.Config, names []string) bool { |
| 71 | if c == nil || len(names) < 2 { |
| 72 | return false |
| 73 | } |
| 74 | group := "" |
| 75 | for _, name := range names { |
| 76 | current := customProviderGroupKey(name) |
| 77 | if current == "" || (group != "" && current != group) { |
| 78 | return false |
| 79 | } |
| 80 | p, ok := c.Provider(name) |
| 81 | if !ok || isOfficialBuiltInProvider(*p) { |
| 82 | return false |
| 83 | } |
| 84 | group = current |
| 85 | } |
| 86 | return group != "" |
| 87 | } |
| 88 | |
| 89 | func customProviderGroupKey(name string) string { |
| 90 | switch strings.TrimSpace(name) { |
| 91 | case "opencode-go", "opencode-go-anthropic", "opencode-go-responses", |
| 92 | "opencode-go-deepseek-anthropic", "opencode-go-deepseek-responses": |
| 93 | return "opencode-go" |
| 94 | default: |
| 95 | return "" |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func validateOfficialProviderRemoval(c *config.Config, names []string) error { |
| 100 | officialKind := "" |
| 101 | for _, name := range names { |
| 102 | p, ok := c.Provider(name) |
| 103 | if !ok { |
| 104 | return fmt.Errorf("remove provider access: provider %q not found", name) |
| 105 | } |
| 106 | kind := officialProviderKindFromEntry(*p) |
| 107 | if kind == "" { |
| 108 | return fmt.Errorf("remove provider access: provider %q is no longer an official provider", name) |
| 109 | } |
| 110 | if officialKind != "" && kind != officialKind { |
| 111 | return fmt.Errorf("remove provider access: providers do not belong to one official group") |
| 112 | } |
| 113 | officialKind = kind |
| 114 | } |
| 115 | return nil |
| 116 | } |
| 117 | |
| 118 | func lockProviderRemovalState() (func(), error) { |
| 119 | unlockConfig := config.LockUserConfigEdits() |
| 120 | unlockCredentials, err := config.LockUserCredentialEdits() |
| 121 | if err != nil { |
| 122 | unlockConfig() |
| 123 | return nil, err |
| 124 | } |
| 125 | return func() { |
| 126 | unlockCredentials() |
| 127 | unlockConfig() |
| 128 | }, nil |
| 129 | } |
| 130 | |
| 131 | func (a *App) loadProviderRemovalConfigForEdit(root string) (*config.Config, string, error) { |
| 132 | cfg, path, err := a.loadDesktopUserConfigForEditForRoot(root) |
| 133 | if err != nil { |
| 134 | return nil, "", err |
| 135 | } |
| 136 | for i := range cfg.Providers { |
| 137 | cfg.Providers[i].ResolveAPIKeyForRoot(root) |
| 138 | } |
| 139 | return cfg, path, nil |
| 140 | } |
| 141 | |
| 142 | // providerRemovalStateFingerprint covers every config or credential-store |
| 143 | // value used to classify a removal, choose its fallback, and resolve model |
| 144 | // references. The keyed digest remains process-local and contains no raw secret. |
| 145 | func providerRemovalStateFingerprint(c *config.Config, credentialsRevision string) string { |
| 146 | h := hmac.New(sha256.New, providerStateFingerprintKey) |
| 147 | write := func(value string) { |
| 148 | _, _ = fmt.Fprintf(h, "%d:", len(value)) |
| 149 | _, _ = h.Write([]byte(value)) |
| 150 | } |
| 151 | write("provider-removal-state-v3") |
| 152 | write(credentialsRevision) |
| 153 | write(c.DefaultModel) |
| 154 | write(c.Agent.PlannerModel) |
| 155 | write(c.Agent.VisionModel) |
| 156 | write(c.Agent.GuardianModel) |
| 157 | write(c.Agent.RecoveryModel) |
| 158 | write(c.Agent.SubagentModel) |
| 159 | write(c.Bot.Model) |
| 160 | write(c.Bot.QQ.Model) |
| 161 | write(c.Bot.Dingtalk.Model) |
| 162 | for i := range c.Bot.Routes { |
| 163 | write(c.Bot.Routes[i].Model) |
| 164 | } |
| 165 | for i := range c.Bot.Connections { |
| 166 | write(c.Bot.Connections[i].Model) |
| 167 | } |
| 168 | for _, name := range c.Desktop.ProviderAccess { |
| 169 | write(name) |
| 170 | } |
| 171 | skills := make([]string, 0, len(c.Agent.SubagentModels)) |
| 172 | for skill := range c.Agent.SubagentModels { |
| 173 | skills = append(skills, skill) |
| 174 | } |
| 175 | sort.Strings(skills) |
| 176 | for _, skill := range skills { |
| 177 | write(skill) |
| 178 | write(c.Agent.SubagentModels[skill]) |
| 179 | } |
| 180 | for i := range c.Providers { |
| 181 | p := &c.Providers[i] |
| 182 | write(p.Name) |
| 183 | write(p.Kind) |
| 184 | write(p.BaseURL) |
| 185 | write(p.APIKeyEnv) |
| 186 | write(fmt.Sprintf("%t", p.Configured())) |
| 187 | write(p.Model) |
| 188 | write(p.Default) |
| 189 | for _, model := range p.Models { |
| 190 | write(model) |
| 191 | } |
| 192 | } |
| 193 | return string(h.Sum(nil)) |
| 194 | } |
| 195 | |
| 196 | func officialProviderRemovalTargets(names []string) []string { |
| 197 | targets := append([]string(nil), names...) |
| 198 | for _, name := range names { |
| 199 | switch canonical := config.CanonicalDesktopOfficialProviderName(name); canonical { |
| 200 | case "deepseek": |
| 201 | targets = append(targets, canonical, "deepseek-flash", "deepseek-pro") |
| 202 | default: |
| 203 | targets = append(targets, canonical) |
| 204 | } |
| 205 | } |
| 206 | return uniqueNonEmptyStrings(targets) |
| 207 | } |
| 208 | |
| 209 | func providerAccessFallbackRef(c *config.Config, names []string) string { |
| 210 | removed := providerAccessSet(names) |
| 211 | for _, candidate := range c.Desktop.ProviderAccess { |
| 212 | candidate = strings.TrimSpace(candidate) |
| 213 | if candidate == "" || removed[candidate] { |
| 214 | continue |
| 215 | } |
| 216 | p, ok := c.Provider(candidate) |
| 217 | if ok && p.Configured() && len(p.ModelList()) > 0 { |
| 218 | return p.Name + "/" + p.DefaultModel() |
| 219 | } |
| 220 | } |
| 221 | return "" |
| 222 | } |
| 223 | |
| 224 | func providerRefMatchesAny(c *config.Config, ref string, names []string) bool { |
| 225 | for _, name := range names { |
| 226 | if desktopModelRefsProvider(c, ref, name) { |
| 227 | return true |
| 228 | } |
| 229 | } |
| 230 | return false |
| 231 | } |
| 232 | |
| 233 | func retargetProviderReferences(c *config.Config, names []string, fallbackRef string) { |
| 234 | fallbackRef = strings.TrimSpace(fallbackRef) |
| 235 | if providerRefMatchesAny(c, c.DefaultModel, names) { |
| 236 | c.DefaultModel = fallbackRef |
| 237 | } |
| 238 | if providerRefMatchesAny(c, c.Agent.PlannerModel, names) { |
| 239 | c.Agent.PlannerModel = fallbackRef |
| 240 | } |
| 241 | if providerRefMatchesAny(c, c.Agent.VisionModel, names) { |
| 242 | c.Agent.VisionModel = "" |
| 243 | } |
| 244 | if providerRefMatchesAny(c, c.Agent.GuardianModel, names) { |
| 245 | c.Agent.GuardianModel = fallbackRef |
| 246 | } |
| 247 | if providerRefMatchesAny(c, c.Agent.RecoveryModel, names) { |
| 248 | c.Agent.RecoveryModel = fallbackRef |
| 249 | } |
| 250 | if providerRefMatchesAny(c, c.Agent.SubagentModel, names) { |
| 251 | c.Agent.SubagentModel = fallbackRef |
| 252 | } |
| 253 | for skill, ref := range c.Agent.SubagentModels { |
| 254 | if providerRefMatchesAny(c, ref, names) { |
| 255 | if fallbackRef == "" { |
| 256 | delete(c.Agent.SubagentModels, skill) |
| 257 | } else { |
| 258 | c.Agent.SubagentModels[skill] = fallbackRef |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | if providerRefMatchesAny(c, c.Bot.Model, names) { |
| 263 | c.Bot.Model = fallbackRef |
| 264 | } |
| 265 | if providerRefMatchesAny(c, c.Bot.QQ.Model, names) { |
| 266 | c.Bot.QQ.Model = fallbackRef |
| 267 | } |
| 268 | if providerRefMatchesAny(c, c.Bot.Dingtalk.Model, names) { |
| 269 | c.Bot.Dingtalk.Model = fallbackRef |
| 270 | } |
| 271 | for i := range c.Bot.Routes { |
| 272 | if providerRefMatchesAny(c, c.Bot.Routes[i].Model, names) { |
| 273 | c.Bot.Routes[i].Model = fallbackRef |
| 274 | } |
| 275 | } |
| 276 | for i := range c.Bot.Connections { |
| 277 | if providerRefMatchesAny(c, c.Bot.Connections[i].Model, names) { |
| 278 | c.Bot.Connections[i].Model = fallbackRef |
| 279 | } |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func (a *App) planProviderRemoval(names []string, official bool) (providerRemovalPlan, error) { |
| 284 | root := a.activeWorkspaceRoot() |
| 285 | unlock, err := lockProviderRemovalState() |
| 286 | if err != nil { |
| 287 | return providerRemovalPlan{}, err |
| 288 | } |
| 289 | defer unlock() |
| 290 | cfg, _, err := a.loadProviderRemovalConfigForEdit(root) |
| 291 | if err != nil { |
| 292 | return providerRemovalPlan{}, err |
| 293 | } |
| 294 | if official { |
| 295 | if err := validateOfficialProviderRemoval(cfg, names); err != nil { |
| 296 | return providerRemovalPlan{}, err |
| 297 | } |
| 298 | } else { |
| 299 | if len(names) > 1 && !isAtomicCustomProviderGroup(cfg, names) { |
| 300 | return providerRemovalPlan{}, fmt.Errorf("remove provider: custom provider group is not supported") |
| 301 | } |
| 302 | for _, name := range names { |
| 303 | p, ok := cfg.Provider(name) |
| 304 | if !ok { |
| 305 | return providerRemovalPlan{}, fmt.Errorf("remove provider: %q not found", name) |
| 306 | } |
| 307 | if isOfficialBuiltInProvider(*p) { |
| 308 | return providerRemovalPlan{}, fmt.Errorf("remove provider: %q is now an official provider; retry", name) |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | targets := names |
| 313 | fallbackRef := providerAccessFallbackRef(cfg, targets) |
| 314 | if official { |
| 315 | targets = officialProviderRemovalTargets(names) |
| 316 | fallbackRef = providerAccessFallbackRef(cfg, targets) |
| 317 | } |
| 318 | return providerRemovalPlan{ |
| 319 | config: cfg, root: root, targets: targets, fallbackRef: fallbackRef, |
| 320 | fingerprint: providerRemovalStateFingerprint(cfg, providerCredentialsRevision()), |
| 321 | }, nil |
| 322 | } |
| 323 | |
| 324 | func validateProviderRemovalFingerprint(fresh *config.Config, planned string) error { |
| 325 | if providerRemovalStateFingerprint(fresh, providerCredentialsRevision()) != planned { |
| 326 | return fmt.Errorf("provider configuration or credentials changed while removing access; retry") |
| 327 | } |
| 328 | return nil |
| 329 | } |
| 330 | |
| 331 | func (a *App) commitOfficialProviderRemoval(plan providerRemovalPlan, names []string) (string, error) { |
| 332 | unlock, err := lockProviderRemovalState() |
| 333 | if err != nil { |
| 334 | return "", err |
| 335 | } |
| 336 | defer unlock() |
| 337 | fresh, path, err := a.loadProviderRemovalConfigForEdit(plan.root) |
| 338 | if err != nil { |
| 339 | return "", err |
| 340 | } |
| 341 | if err := validateOfficialProviderRemoval(fresh, names); err != nil { |
| 342 | return "", fmt.Errorf("provider configuration changed while removing access; retry: %w", err) |
| 343 | } |
| 344 | if err := validateProviderRemovalFingerprint(fresh, plan.fingerprint); err != nil { |
| 345 | return "", err |
| 346 | } |
| 347 | baseline := fresh.ModelSettingsBaseline() |
| 348 | fallbackRef := providerAccessFallbackRef(fresh, plan.targets) |
| 349 | retargetProviderReferences(fresh, plan.targets, fallbackRef) |
| 350 | removeProviderAccess(fresh, plan.targets...) |
| 351 | return fallbackRef, fresh.SaveModelSettingsTo(path, baseline) |
| 352 | } |
| 353 | |
| 354 | func (a *App) commitCustomProviderRemovals(plan providerRemovalPlan) (string, error) { |
| 355 | unlock, err := lockProviderRemovalState() |
| 356 | if err != nil { |
| 357 | return "", err |
| 358 | } |
| 359 | defer unlock() |
| 360 | fresh, path, err := a.loadProviderRemovalConfigForEdit(plan.root) |
| 361 | if err != nil { |
| 362 | return "", err |
| 363 | } |
| 364 | for _, name := range plan.targets { |
| 365 | p, ok := fresh.Provider(name) |
| 366 | if !ok { |
| 367 | return "", fmt.Errorf("provider configuration changed while removing %q; retry", name) |
| 368 | } |
| 369 | if isOfficialBuiltInProvider(*p) { |
| 370 | return "", fmt.Errorf("provider configuration changed while removing %q; it is now an official provider; retry", name) |
| 371 | } |
| 372 | } |
| 373 | if err := validateProviderRemovalFingerprint(fresh, plan.fingerprint); err != nil { |
| 374 | return "", err |
| 375 | } |
| 376 | baseline := fresh.ModelSettingsBaseline() |
| 377 | fallbackRef := providerAccessFallbackRef(fresh, plan.targets) |
| 378 | // Config.RemoveProvider has a compatibility fallback across every configured |
| 379 | // provider. Settings access removal is narrower: hidden providers must not |
| 380 | // silently become the new default after restart. Retarget first so the |
| 381 | // persisted config and every rebuilt tab use the same visible provider. Keep |
| 382 | // the historical provider-only persisted form while the runtime uses the |
| 383 | // exact provider/model reference returned below. |
| 384 | persistedFallback := fallbackRef |
| 385 | if providerName, _, ok := strings.Cut(fallbackRef, "/"); ok { |
| 386 | persistedFallback = providerName |
| 387 | } |
| 388 | retargetProviderReferences(fresh, plan.targets, persistedFallback) |
| 389 | for _, name := range plan.targets { |
| 390 | if err := fresh.RemoveProvider(name); err != nil { |
| 391 | return "", err |
| 392 | } |
| 393 | } |
| 394 | removeProviderAccess(fresh, plan.targets...) |
| 395 | return fallbackRef, fresh.SaveModelSettingsTo(path, baseline) |
| 396 | } |
| 397 | |
| 398 | func (a *App) removeBuiltInProviderAccessAndRetargetTabs(names []string) error { |
| 399 | plan, err := a.planProviderRemoval(names, true) |
| 400 | if err != nil { |
| 401 | return err |
| 402 | } |
| 403 | if _, err := a.commitOfficialProviderRemoval(plan, names); err != nil { |
| 404 | return err |
| 405 | } |
| 406 | a.modelSettingsSaved("provider access") |
| 407 | return nil |
| 408 | } |
| 409 | |
| 410 | func (a *App) deleteProviderAndRetargetTabs(name string) error { |
| 411 | return a.deleteProvidersAndRetargetTabs([]string{name}) |
| 412 | } |
| 413 | |
| 414 | func (a *App) deleteProvidersAndRetargetTabs(rawNames []string) error { |
| 415 | names := uniqueNonEmptyStrings(rawNames) |
| 416 | if len(names) == 0 { |
| 417 | return fmt.Errorf("remove provider: empty provider name") |
| 418 | } |
| 419 | plan, err := a.planProviderRemoval(names, false) |
| 420 | if err != nil { |
| 421 | return err |
| 422 | } |
| 423 | if _, err := a.commitCustomProviderRemovals(plan); err != nil { |
| 424 | return err |
| 425 | } |
| 426 | a.modelSettingsSaved("provider") |
| 427 | return nil |
| 428 | } |
| 429 |