返回 DeepSeek-Reasonix
provider_access_removal.go
根目录 / desktop / provider_access_removal.go
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
429 lines GO