返回 DeepSeek-Reasonix
settings_app.go
根目录 / desktop / settings_app.go
1 package main
2
3 import (
4 "context"
5 "crypto/hmac"
6 "crypto/rand"
7 "crypto/sha256"
8 "errors"
9 "fmt"
10 "log/slog"
11 "math"
12 "net/url"
13 "os"
14 "path/filepath"
15 "runtime"
16 "slices"
17 "sort"
18 "strings"
19 "sync"
20 "time"
21
22 "golang.org/x/sync/errgroup"
23
24 "reasonix/internal/agent"
25 "reasonix/internal/boot"
26 "reasonix/internal/bot"
27 "reasonix/internal/botruntime"
28 "reasonix/internal/config"
29 "reasonix/internal/control"
30 "reasonix/internal/netclient"
31 "reasonix/internal/provider"
32 "reasonix/internal/sandbox"
33 )
34
35 // settings_app.go is the desktop Settings panel's command surface: it reads the
36 // resolved config and applies edits through internal/config/edit.go (the
37 // purpose-built mutation API), then rebuilds the controller so the change takes
38 // effect live — the same snapshot→reload→resume pattern as SetModel. Secrets are
39 // the exception: they go to Reasonix's global .env (upsertDotEnv), since config
40 // stores only the env-var name, not the key.
41
42 // read
43
44 type ProviderView struct {
45 DisplayName *string `json:"displayName,omitempty"`
46 Name string `json:"name"`
47 PresetID string `json:"presetId,omitempty"`
48 Catalog *config.ProviderCatalog `json:"catalog,omitempty"`
49 BuiltIn bool `json:"builtIn"`
50 Added bool `json:"added"`
51 Kind string `json:"kind"`
52 BaseURL string `json:"baseUrl"`
53 ChatURL string `json:"chatUrl"`
54 RequestURL string `json:"requestUrl"`
55 Models []string `json:"models"`
56 VisionModels []string `json:"visionModels"` // legacy capability projection for old frontends
57 VisionModelsSet bool `json:"visionModelsConfigured"` // legacy explicit-list marker
58 VisionCapability string `json:"visionCapability,omitempty"`
59 ModelsURL string `json:"modelsUrl"`
60 Default string `json:"default"`
61 APIKeyEnv string `json:"apiKeyEnv"`
62 Headers map[string]string `json:"headers"`
63 ExtraBody map[string]any `json:"extraBody"`
64 AuthHeader bool `json:"authHeader"`
65 NoProxy bool `json:"noProxy"`
66 KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
67 RequiresKey bool `json:"requiresKey"`
68 Configured bool `json:"configured"` // selectable: either key is present or no key is required
69 KeySource string `json:"keySource,omitempty"`
70 KeySourcePath string `json:"keySourcePath,omitempty"`
71 BalanceURL string `json:"balanceUrl"`
72 ContextWindow int `json:"contextWindow"`
73 ReasoningProtocol string `json:"reasoningProtocol"`
74 Thinking string `json:"thinking"`
75 WebSearch bool `json:"webSearch"`
76 ServerWebSearchCapability bool `json:"serverWebSearchCapability"`
77 SupportedEfforts []string `json:"supportedEfforts"`
78 DefaultEffort string `json:"defaultEffort"`
79 ModelOverrides []ProviderModelOverrideView `json:"modelOverrides"`
80 ModelCapabilities []ProviderModelCapabilityView `json:"modelCapabilities"`
81 RecommendedUpgradeAvailable bool `json:"recommendedUpgradeAvailable,omitempty"`
82 // ModelCatalogFingerprint is an opaque digest of the provider identity and
83 // current model selection. Background discovery must compare it while holding
84 // the config edit lock before applying a narrow catalog-only update.
85 ModelCatalogFingerprint string `json:"modelCatalogFingerprint"`
86 }
87
88 type ProviderModelCapabilityView struct {
89 Reasoning *config.ResolvedReasoningView `json:"reasoning,omitempty"`
90 Model string `json:"model"`
91 InputModalities []string `json:"inputModalities"`
92 State string `json:"state"`
93 Source string `json:"source"`
94 AutomaticState string `json:"automaticState"`
95 AutomaticSource string `json:"automaticSource"`
96 ImageInputEnableAllowed bool `json:"imageInputEnableAllowed"`
97 ImageInputBlockReason string `json:"imageInputBlockReason,omitempty"`
98 }
99
100 type ProviderModelCatalogUpdate struct {
101 Name string `json:"name"`
102 ExpectedFingerprint string `json:"expectedFingerprint"`
103 Models []string `json:"models"`
104 Default string `json:"default"`
105 VisionModels []string `json:"visionModels"`
106 ModelCapabilities []ProviderModelCapabilityUpdate `json:"modelCapabilities,omitempty"`
107 }
108
109 type ProviderModelCapabilityUpdate struct {
110 Model string `json:"model"`
111 InputModalities []string `json:"inputModalities"`
112 }
113 type ProviderPresetView struct {
114 Catalog config.ProviderCatalog `json:"catalog"`
115 ID string `json:"id"`
116 Label string `json:"label"`
117 Description string `json:"description"`
118 KeyEnv string `json:"keyEnv"`
119 Recommended bool `json:"recommended,omitempty"`
120 BillingMode string `json:"billingMode,omitempty"`
121 DisplayGroup string `json:"displayGroup,omitempty"`
122 DisplaySection string `json:"displaySection,omitempty"`
123 DisplayTier string `json:"displayTier,omitempty"`
124 RouteKind string `json:"routeKind,omitempty"`
125 Optional bool `json:"optional,omitempty"`
126 DisplayOrder int `json:"displayOrder,omitempty"`
127 ProviderNames []string `json:"providerNames"`
128 Models []string `json:"models"`
129 Added bool `json:"added"`
130 Status string `json:"status"`
131 StatusProviderNames []string `json:"statusProviderNames"`
132 MissingProviderNames []string `json:"missingProviderNames,omitempty"`
133 KeySet bool `json:"keySet"`
134 RequiresKey bool `json:"requiresKey"`
135 Configured bool `json:"configured"`
136 KeySource string `json:"keySource,omitempty"`
137 KeySourcePath string `json:"keySourcePath,omitempty"`
138 }
139
140 const (
141 providerPresetStatusAvailable = "available"
142 providerPresetStatusInstalled = "installed"
143 providerPresetStatusPartial = "partial"
144 providerPresetStatusInstalledModified = "installed_modified"
145 providerPresetStatusNameConflict = "name_conflict"
146 providerPresetStatusSimilarExisting = "similar_existing"
147 )
148
149 type ProviderModelOverrideView struct {
150 Model string `json:"model"`
151 ReasoningProtocol string `json:"reasoningProtocol"`
152 Thinking string `json:"thinking"`
153 SupportedEfforts []string `json:"supportedEfforts"`
154 DefaultEffort string `json:"defaultEffort"`
155 Vision *bool `json:"vision"`
156 ContextWindow int `json:"contextWindow,omitempty"`
157 MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
158 }
159
160 type PermissionsView struct {
161 Mode string `json:"mode"`
162 Allow []string `json:"allow"`
163 Ask []string `json:"ask"`
164 Deny []string `json:"deny"`
165 }
166
167 type NetworkProxyView struct {
168 Type string `json:"type"`
169 Server string `json:"server"`
170 Port int `json:"port"`
171 Username string `json:"username"`
172 Password string `json:"password"`
173 }
174
175 type NetworkView struct {
176 ProxyMode string `json:"proxyMode"`
177 ProxyURL string `json:"proxyUrl"`
178 NoProxy string `json:"noProxy"`
179 Proxy NetworkProxyView `json:"proxy"`
180 }
181
182 type AgentView struct {
183 Temperature float64 `json:"temperature"`
184 MaxSteps int `json:"maxSteps"`
185 PlannerMaxSteps int `json:"plannerMaxSteps"`
186 MaxSubagentDepth int `json:"maxSubagentDepth"`
187 MaxSubagentConcurrency int `json:"maxSubagentConcurrency"`
188 MaxParallelWriters int `json:"maxParallelWriters"`
189 SystemPrompt string `json:"systemPrompt"`
190 ReasoningLanguage string `json:"reasoningLanguage"`
191 CompactRatio float64 `json:"compactRatio,omitempty"`
192 EffectiveCompactRatio float64 `json:"effectiveCompactRatio,omitempty"`
193 CompactRatioOverridden bool `json:"compactRatioOverridden,omitempty"`
194 }
195
196 type BotAllowlistView struct {
197 Enabled bool `json:"enabled"`
198 AllowAll bool `json:"allowAll"`
199 QQUsers []string `json:"qqUsers"`
200 FeishuUsers []string `json:"feishuUsers"`
201 WeixinUsers []string `json:"weixinUsers"`
202 QQApprovers []string `json:"qqApprovers"`
203 FeishuApprovers []string `json:"feishuApprovers"`
204 WeixinApprovers []string `json:"weixinApprovers"`
205 QQAdmins []string `json:"qqAdmins"`
206 FeishuAdmins []string `json:"feishuAdmins"`
207 WeixinAdmins []string `json:"weixinAdmins"`
208 QQGroups []string `json:"qqGroups"`
209 FeishuGroups []string `json:"feishuGroups"`
210 WeixinGroups []string `json:"weixinGroups"`
211 DingtalkUsers []string `json:"dingtalkUsers"`
212 DingtalkApprovers []string `json:"dingtalkApprovers"`
213 DingtalkAdmins []string `json:"dingtalkAdmins"`
214 DingtalkGroups []string `json:"dingtalkGroups"`
215 }
216
217 type BotAccessView struct {
218 Enabled bool `json:"enabled"`
219 AllowAll bool `json:"allowAll"`
220 PairingEnabled bool `json:"pairingEnabled"`
221 Users []string `json:"users"`
222 Groups []string `json:"groups"`
223 Approvers []string `json:"approvers"`
224 Admins []string `json:"admins"`
225 }
226
227 type BotSelfUserIDsView struct {
228 QQ []string `json:"qq"`
229 Feishu []string `json:"feishu"`
230 Weixin []string `json:"weixin"`
231 Dingtalk []string `json:"dingtalk"`
232 }
233
234 type BotPairingView struct {
235 Enabled bool `json:"enabled"`
236 RequestTTLMinutes int `json:"requestTtlMinutes"`
237 MaxPendingPerPlatform int `json:"maxPendingPerPlatform"`
238 }
239
240 type BotControlView struct {
241 Enabled bool `json:"enabled"`
242 Addr string `json:"addr"`
243 TokenEnv string `json:"tokenEnv"`
244 }
245
246 type BotRouteView struct {
247 ConnectionID string `json:"connectionId"`
248 Platform string `json:"platform"`
249 ChatType string `json:"chatType"`
250 ChatID string `json:"chatId"`
251 UserID string `json:"userId"`
252 ThreadID string `json:"threadId"`
253 Model string `json:"model"`
254 ToolApprovalMode string `json:"toolApprovalMode"`
255 WorkspaceRoot string `json:"workspaceRoot"`
256 }
257
258 type QQBotView struct {
259 Enabled bool `json:"enabled"`
260 AppID string `json:"appId"`
261 AppSecretEnv string `json:"appSecretEnv"`
262 SecretSet bool `json:"secretSet"`
263 Sandbox bool `json:"sandbox"`
264 Model string `json:"model"`
265 ToolApprovalMode string `json:"toolApprovalMode"`
266 WorkspaceRoot string `json:"workspaceRoot"`
267 Access BotAccessView `json:"access"`
268 }
269
270 type FeishuBotView struct {
271 Enabled bool `json:"enabled"`
272 Domain string `json:"domain"`
273 AppID string `json:"appId"`
274 AppSecretEnv string `json:"appSecretEnv"`
275 SecretSet bool `json:"secretSet"`
276 VerificationToken string `json:"verificationToken"`
277 Mode string `json:"mode"`
278 WebhookPort int `json:"webhookPort"`
279 RequireMention bool `json:"requireMention"`
280 }
281
282 type WeixinBotView struct {
283 Enabled bool `json:"enabled"`
284 AccountID string `json:"accountId"`
285 TokenEnv string `json:"tokenEnv"`
286 TokenSet bool `json:"tokenSet"`
287 APIBase string `json:"apiBase"`
288 }
289
290 type DingtalkBotView struct {
291 Enabled bool `json:"enabled"`
292 ClientID string `json:"clientId"`
293 ClientSecretEnv string `json:"clientSecretEnv"`
294 SecretSet bool `json:"secretSet"`
295 BotName string `json:"botName"`
296 RequireMention bool `json:"requireMention"`
297 Model string `json:"model"`
298 ToolApprovalMode string `json:"toolApprovalMode"`
299 WorkspaceRoot string `json:"workspaceRoot"`
300 Access BotAccessView `json:"access"`
301 }
302
303 type BotSettingsView struct {
304 Enabled bool `json:"enabled"`
305 Model string `json:"model"`
306 ToolApprovalMode string `json:"toolApprovalMode"`
307 MaxSteps int `json:"maxSteps"`
308 DebounceMs int `json:"debounceMs"`
309 QueueMode string `json:"queueMode"`
310 QueueCap int `json:"queueCap"`
311 QueueDrop string `json:"queueDrop"`
312 IgnoreSelfMessages bool `json:"ignoreSelfMessages"`
313 SelfUserIDs BotSelfUserIDsView `json:"selfUserIds"`
314 Control BotControlView `json:"control"`
315 Pairing BotPairingView `json:"pairing"`
316 Routes []BotRouteView `json:"routes"`
317 Allowlist BotAllowlistView `json:"allowlist"`
318 QQ QQBotView `json:"qq"`
319 Feishu FeishuBotView `json:"feishu"`
320 Weixin WeixinBotView `json:"weixin"`
321 Dingtalk DingtalkBotView `json:"dingtalk"`
322 Connections []BotConnectionView `json:"connections"`
323 }
324
325 // SettingsView is the whole Settings panel payload.
326 type SettingsView struct {
327 ModelSettingsFingerprint string `json:"modelSettingsFingerprint"`
328 DefaultModel string `json:"defaultModel"`
329 PlannerModel string `json:"plannerModel"`
330 VisionModel string `json:"visionModel"`
331 WebSearchModel string `json:"webSearchModel"`
332 WebSearchModels []string `json:"webSearchModels"`
333 WebSearchModelStatus string `json:"webSearchModelStatus"`
334 WebSearchModelReason string `json:"webSearchModelReason"`
335 EffectiveWebSearchModel string `json:"effectiveWebSearchModel"`
336 WebSearchModelOverridden bool `json:"webSearchModelOverridden"`
337 SubagentModel string `json:"subagentModel"`
338 SubagentEffort string `json:"subagentEffort"`
339 AutoPlan string `json:"autoPlan"`
340 Providers []ProviderView `json:"providers"`
341 OfficialProviders []ProviderView `json:"officialProviders"`
342 ProviderPresets []ProviderPresetView `json:"providerPresets"`
343 Permissions PermissionsView `json:"permissions"`
344 Sandbox SandboxView `json:"sandbox"`
345 Network NetworkView `json:"network"`
346 Agent AgentView `json:"agent"`
347 Bot BotSettingsView `json:"bot"`
348 DesktopLanguage string `json:"desktopLanguage"`
349 DesktopCurrency string `json:"desktopCurrency"`
350 DesktopLayoutStyle string `json:"desktopLayoutStyle"`
351 DesktopTheme string `json:"desktopTheme"`
352 DesktopThemeStyle string `json:"desktopThemeStyle"`
353 DesktopTerminalTheme string `json:"desktopTerminalTheme,omitempty"`
354 CloseBehavior string `json:"closeBehavior"`
355 SessionExperience string `json:"sessionExperience"`
356 DisplayMode string `json:"displayMode"`
357 ReasoningDisplayMode string `json:"reasoningDisplayMode"`
358 ReasoningDisplayModeExplicit bool `json:"reasoningDisplayModeExplicit"`
359 StatusBarStyle string `json:"statusBarStyle"`
360 StatusBarItems []string `json:"statusBarItems"`
361 DefaultToolApprovalMode string `json:"defaultToolApprovalMode"`
362
363 CheckUpdates bool `json:"checkUpdates"`
364 UpdaterEnabled bool `json:"updaterEnabled"`
365 UpdateChannel string `json:"updateChannel"`
366 Telemetry bool `json:"telemetry"`
367 Metrics bool `json:"metrics"`
368 ExpandThinking bool `json:"expandThinking"`
369 ConversationWidth string `json:"conversationWidth,omitempty"`
370 ConfigPath string `json:"configPath"`
371 // ShadowedByPath is the workspace reasonix.toml that outranks the file this
372 // panel writes, so an edit here can be overridden with nothing on screen to
373 // explain it (#4333). Empty when the panel's file is the one in effect.
374 ShadowedByPath string `json:"shadowedByPath,omitempty"`
375 // ProviderKinds lists the provider implementations the kernel actually
376 // registered (provider.Kinds()), so the editor's "kind" picker offers only
377 // kinds that resolve — selecting an unregistered one would fail the rebuild.
378 ProviderKinds []string `json:"providerKinds"`
379 // AutoApproveTools is the live YOLO/full-access state (runtime-only, not from
380 // config), so the panel's toggle reflects whether tool approvals are currently
381 // being skipped this session.
382 AutoApproveTools bool `json:"autoApproveTools"`
383 // Bypass is the legacy JSON key for the same live state.
384 Bypass bool `json:"bypass"`
385 }
386
387 // shadowingConfigPath returns the config file that outranks writePath for the
388 // workspace at root, or "" when writePath is the one in effect. A project
389 // reasonix.toml beats the user config, so settings written here would otherwise
390 // look ignored (#4333).
391 func shadowingConfigPath(writePath, root string) string {
392 effective := config.SourcePathForRoot(root)
393 if effective == "" || samePath(effective, writePath) {
394 return ""
395 }
396 if abs, err := filepath.Abs(effective); err == nil {
397 return abs
398 }
399 return effective
400 }
401
402 func samePath(a, b string) bool {
403 absA, errA := filepath.Abs(a)
404 absB, errB := filepath.Abs(b)
405 if errA != nil || errB != nil {
406 return a == b
407 }
408 if runtime.GOOS == "windows" {
409 return strings.EqualFold(filepath.Clean(absA), filepath.Clean(absB))
410 }
411 return filepath.Clean(absA) == filepath.Clean(absB)
412 }
413
414 func nonNil(s []string) []string {
415 if s == nil {
416 return []string{}
417 }
418 return s
419 }
420
421 func nonNilStringMap(m map[string]string) map[string]string {
422 if m == nil {
423 return map[string]string{}
424 }
425 return m
426 }
427
428 func nonNilAnyMap(m map[string]any) map[string]any {
429 if m == nil {
430 return map[string]any{}
431 }
432 return m
433 }
434
435 func providerCredentialsRevision() string {
436 return config.CredentialStoreRevision()
437 }
438
439 var providerStateFingerprintKey = func() []byte {
440 key := make([]byte, 32)
441 if _, err := rand.Read(key); err != nil {
442 panic(fmt.Sprintf("initialize provider state fingerprint key: %v", err))
443 }
444 return key
445 }()
446
447 func providerModelCatalogFingerprint(p config.ProviderEntry) string {
448 return providerModelCatalogFingerprintForCredentials(p, providerCredentialsRevision())
449 }
450
451 func providerModelCatalogFingerprintForCredentials(p config.ProviderEntry, credentialsRevision string) string {
452 // This token crosses the bridge boundary, so key the digest instead of exposing
453 // a reusable hash of header or credential-store metadata to the frontend.
454 h := hmac.New(sha256.New, providerStateFingerprintKey)
455 write := func(value string) {
456 _, _ = fmt.Fprintf(h, "%d:", len(value))
457 _, _ = h.Write([]byte(value))
458 }
459 write("provider-model-catalog-v1")
460 write("name")
461 write(p.Name)
462 write("kind")
463 write(p.Kind)
464 write("base_url")
465 write(p.BaseURL)
466 write("models_url")
467 write(p.ModelsURL)
468 write(p.ChatURL)
469 write(p.RequestURL)
470 write(fmt.Sprintf("%t", p.NoProxy))
471 write("api_key_env")
472 write(p.APIKeyEnv)
473 write("credentials_revision")
474 write(credentialsRevision)
475 write("auth_header")
476 write(fmt.Sprintf("%t", p.AuthHeader))
477 keys := make([]string, 0, len(p.Headers))
478 for key := range p.Headers {
479 keys = append(keys, key)
480 }
481 sort.Strings(keys)
482 write("headers")
483 write(fmt.Sprintf("%d", len(keys)))
484 for _, key := range keys {
485 write(key)
486 write(p.Headers[key])
487 }
488 write("model")
489 write(p.Model)
490 write("models")
491 write(fmt.Sprintf("%d", len(p.Models)))
492 for _, model := range p.Models {
493 write(model)
494 }
495 write("default")
496 write(p.Default)
497 write("vision")
498 write(fmt.Sprintf("%t", p.Vision))
499 write("vision_models")
500 write(fmt.Sprintf("%d", len(p.VisionModels)))
501 for _, model := range p.VisionModels {
502 write(model)
503 }
504 return fmt.Sprintf("%x", h.Sum(nil))
505 }
506
507 func providerModelOverridesForView(overrides map[string]config.ProviderModelOverride, models []string) []ProviderModelOverrideView {
508 if len(overrides) == 0 {
509 return []ProviderModelOverrideView{}
510 }
511 modelSet := map[string]bool{}
512 for _, model := range models {
513 modelSet[model] = true
514 }
515 keys := make([]string, 0, len(overrides))
516 for model := range overrides {
517 model = strings.TrimSpace(model)
518 if model == "" {
519 continue
520 }
521 if len(modelSet) > 0 && !modelSet[model] {
522 continue
523 }
524 keys = append(keys, model)
525 }
526 sort.Strings(keys)
527 out := make([]ProviderModelOverrideView, 0, len(keys))
528 for _, model := range keys {
529 ov := overrides[model]
530 out = append(out, ProviderModelOverrideView{
531 Model: model,
532 ReasoningProtocol: ov.ReasoningProtocol,
533 SupportedEfforts: nonNil(ov.SupportedEfforts),
534 DefaultEffort: ov.DefaultEffort,
535 Vision: ov.Vision,
536 ContextWindow: ov.ContextWindow,
537 MaxOutputTokens: ov.MaxOutputTokens,
538 })
539 }
540 return out
541 }
542
543 func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models []string) map[string]config.ProviderModelOverride {
544 if len(overrides) == 0 {
545 return nil
546 }
547 modelSet := map[string]bool{}
548 for _, model := range models {
549 modelSet[model] = true
550 }
551 out := map[string]config.ProviderModelOverride{}
552 for _, item := range overrides {
553 model := strings.TrimSpace(item.Model)
554 if model == "" || (len(modelSet) > 0 && !modelSet[model]) {
555 continue
556 }
557 ov := config.ProviderModelOverride{
558 ReasoningProtocol: strings.TrimSpace(item.ReasoningProtocol),
559 SupportedEfforts: nonNil(item.SupportedEfforts),
560 DefaultEffort: strings.TrimSpace(item.DefaultEffort),
561 Vision: item.Vision,
562 ContextWindow: max(item.ContextWindow, 0),
563 MaxOutputTokens: item.MaxOutputTokens,
564 }
565 if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil && ov.ContextWindow == 0 && ov.MaxOutputTokens == 0 {
566 continue
567 }
568 out[model] = ov
569 }
570 if len(out) == 0 {
571 return nil
572 }
573 return out
574 }
575
576 func desktopModelRefsProvider(c *config.Config, ref, name string) bool {
577 if config.ModelRefsProvider(ref, name) {
578 return true
579 }
580 if e, ok := c.ResolveModel(ref); ok {
581 return e.Name == name
582 }
583 return false
584 }
585
586 func officialProviderHost(baseURL string) string {
587 u, err := url.Parse(strings.TrimSpace(baseURL))
588 if err != nil {
589 return ""
590 }
591 return strings.ToLower(u.Hostname())
592 }
593
594 func officialProviderKindFromEntry(p config.ProviderEntry) string {
595 host := officialProviderHost(p.BaseURL)
596 switch config.CanonicalDesktopOfficialProviderName(p.Name) {
597 case "deepseek":
598 if host == "api.deepseek.com" {
599 return "deepseek"
600 }
601 }
602 return ""
603 }
604
605 func isOfficialBuiltInProvider(p config.ProviderEntry) bool {
606 return officialProviderKindFromEntry(p) != ""
607 }
608
609 func providerAccessSet(names []string) map[string]bool {
610 out := map[string]bool{}
611 for _, name := range names {
612 name = strings.TrimSpace(name)
613 if name != "" {
614 out[name] = true
615 }
616 }
617 return out
618 }
619
620 func addProviderAccess(c *config.Config, names ...string) {
621 seen := providerAccessSet(c.Desktop.ProviderAccess)
622 for _, name := range names {
623 name = strings.TrimSpace(name)
624 if name == "" || seen[name] {
625 continue
626 }
627 c.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, name)
628 seen[name] = true
629 }
630 }
631
632 func removeProviderAccess(c *config.Config, names ...string) {
633 remove := providerAccessSet(names)
634 if len(remove) == 0 {
635 return
636 }
637 out := c.Desktop.ProviderAccess[:0]
638 for _, name := range c.Desktop.ProviderAccess {
639 if !remove[name] {
640 out = append(out, name)
641 }
642 }
643 c.Desktop.ProviderAccess = out
644 }
645
646 func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {
647 return providerViewFromEntryForRoot(p, builtIn, added, ".")
648 }
649
650 func providerViewFromEntryForRoot(p config.ProviderEntry, builtIn, added bool, root string) ProviderView {
651 return providerViewFromEntryForRootWithResolver(p, builtIn, added, root, nil)
652 }
653
654 func providerViewFromEntryForRootWithResolver(p config.ProviderEntry, builtIn, added bool, root string, resolver *config.CredentialResolver) ProviderView {
655 return providerViewFromEntryForRootWithResolverAndCredentials(p, builtIn, added, root, resolver, providerCredentialsRevision())
656 }
657
658 func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEntry, builtIn, added bool, root string, resolver *config.CredentialResolver, credentialsRevision string) ProviderView {
659 models := p.ChatModelList()
660 visionModels := p.VisionModels
661 visionModelsSet := p.Vision || p.VisionModels != nil
662 if p.Vision {
663 visionModels = models
664 }
665 if resolver == nil {
666 resolver = config.NewCredentialResolverForRoot(root)
667 }
668 key := resolver.ResolveGlobalFirst(p.APIKeyEnv)
669 requiresKey := p.RequiresAPIKey()
670 visionCapability := "configurable"
671 if !config.CanConfigureVision(&p) {
672 visionCapability = "unsupported"
673 }
674 modelCapabilities := providerModelCapabilitiesForView(p, models)
675 presetID, catalog, hasCatalog := config.CatalogForProviderEntry(&p)
676 var catalogView *config.ProviderCatalog
677 if hasCatalog {
678 catalogView = &catalog
679 }
680 return ProviderView{
681 DisplayName: &p.DisplayName, Name: p.Name, PresetID: presetID, Catalog: catalogView, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL, RequestURL: p.RequestURL,
682 Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, VisionCapability: visionCapability, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
683 APIKeyEnv: p.APIKeyEnv,
684 Headers: nonNilStringMap(p.Headers),
685 ExtraBody: nonNilAnyMap(p.ExtraBody),
686 AuthHeader: p.AuthHeader,
687 NoProxy: p.NoProxy,
688 KeySet: key.Set,
689 RequiresKey: requiresKey,
690 Configured: !requiresKey || key.Set,
691 KeySource: key.Source.Label,
692 KeySourcePath: key.Source.Path,
693 BalanceURL: p.BalanceURL,
694 ContextWindow: p.ContextWindow,
695 ReasoningProtocol: p.ReasoningProtocol,
696 Thinking: providerThinkingForSettings(p.Thinking),
697 WebSearch: config.EffectiveIndependentWebSearch(&p),
698 ServerWebSearchCapability: (config.IsOfficialDeepSeekSearchEndpoint(&p) || config.HasServerWebSearchCapability(&p)),
699 SupportedEfforts: nonNil(p.SupportedEfforts),
700 DefaultEffort: p.DefaultEffort,
701 ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models),
702 ModelCapabilities: modelCapabilities,
703 RecommendedUpgradeAvailable: false, // Chat Completions is the default again; retain the legacy bridge field.
704 ModelCatalogFingerprint: providerModelCatalogFingerprintForCredentials(p, credentialsRevision),
705 }
706 }
707
708 func providerThinkingForSettings(thinking string) string {
709 normalized := strings.ToLower(strings.TrimSpace(thinking))
710 switch normalized {
711 case "enabled", "disabled", "adaptive":
712 return normalized
713 default:
714 return ""
715 }
716 }
717
718 func officialProviderViews(added map[string]bool, pricingLanguage string) []ProviderView {
719 return officialProviderViewsForRoot(added, pricingLanguage, ".")
720 }
721
722 func officialProviderViewsForRoot(added map[string]bool, pricingLanguage, root string) []ProviderView {
723 return officialProviderViewsForRootWithResolver(added, pricingLanguage, root, nil)
724 }
725
726 func officialProviderViewsForRootWithResolver(added map[string]bool, pricingLanguage, root string, resolver *config.CredentialResolver) []ProviderView {
727 var out []ProviderView
728 if resolver == nil {
729 resolver = config.NewCredentialResolverForRoot(root)
730 }
731 credentialsRevision := providerCredentialsRevision()
732 for _, kind := range []string{"deepseek"} {
733 entries, _, err := officialProviderTemplate(kind, pricingLanguage)
734 if err != nil {
735 continue
736 }
737 for _, entry := range entries {
738 out = append(out, providerViewFromEntryForRootWithResolverAndCredentials(entry, true, added[entry.Name], root, resolver, credentialsRevision))
739 }
740 }
741 return out
742 }
743
744 func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderPresetView {
745 if cfg == nil {
746 cfg = &config.Config{}
747 }
748 if resolver == nil {
749 resolver = config.NewCredentialResolverForRoot(root)
750 }
751 presets := config.CuratedProviderPresets()
752 out := make([]ProviderPresetView, 0, len(presets))
753 for _, preset := range presets {
754 keyEnv := strings.TrimSpace(preset.KeyEnv)
755 names := make([]string, 0, len(preset.Entries))
756 models := make([]string, 0)
757 modelSeen := map[string]bool{}
758 requiresKey := false
759 credentialRefs := map[string]bool{}
760 for _, entry := range preset.Entries {
761 if existing, ok := cfg.Provider(entry.Name); ok && (providerEntryCoreMatches(*existing, entry) || providerEntryBelongsToPreset(*existing, preset, entry)) {
762 entry.APIKeyEnv = existing.APIKeyEnv
763 }
764 credentialRefs[entry.APIKeyEnv] = entry.RequiresAPIKey()
765 if keyEnv == "" {
766 keyEnv = strings.TrimSpace(entry.APIKeyEnv)
767 }
768 if entry.RequiresAPIKey() {
769 requiresKey = true
770 }
771 name := strings.TrimSpace(entry.Name)
772 if name != "" {
773 names = append(names, name)
774 }
775 for _, model := range chatProviderModels(entry.ChatModelList()) {
776 if modelSeen[model] {
777 continue
778 }
779 modelSeen[model] = true
780 models = append(models, model)
781 }
782 }
783 key := config.CredentialResolution{}
784 keysSet, configured := true, true
785 for _, entry := range preset.Entries {
786 if existing, ok := cfg.Provider(entry.Name); ok && (providerEntryCoreMatches(*existing, entry) || providerEntryBelongsToPreset(*existing, preset, entry)) {
787 keyEnv = existing.APIKeyEnv
788 break
789 }
790 }
791 if keyEnv != "" {
792 key = resolver.ResolveGlobalFirst(keyEnv)
793 }
794 for env, required := range credentialRefs {
795 resolution := resolver.ResolveGlobalFirst(env)
796 keysSet = keysSet && resolution.Set
797 configured = configured && (!required || resolution.Set)
798 }
799 status, statusNames, missingNames := classifyProviderPresetStatus(cfg, preset)
800 added := status == providerPresetStatusInstalled || status == providerPresetStatusInstalledModified || status == providerPresetStatusNameConflict
801 out = append(out, ProviderPresetView{
802 ID: preset.ID,
803 Catalog: config.CatalogForProviderPreset(preset),
804 Label: preset.Label,
805 Description: preset.Description,
806 KeyEnv: keyEnv,
807 Recommended: preset.Recommended,
808 BillingMode: preset.BillingMode,
809 DisplayGroup: preset.DisplayGroup,
810 DisplaySection: preset.DisplaySection,
811 DisplayTier: preset.DisplayTier,
812 RouteKind: preset.RouteKind,
813 Optional: preset.Optional,
814 DisplayOrder: preset.DisplayOrder,
815 ProviderNames: nonNil(names),
816 Models: nonNil(models),
817 Added: added,
818 Status: status,
819 StatusProviderNames: nonNil(statusNames),
820 MissingProviderNames: nonNil(missingNames),
821 KeySet: keysSet,
822 RequiresKey: requiresKey,
823 Configured: configured,
824 KeySource: key.Source.Label,
825 KeySourcePath: key.Source.Path,
826 })
827 }
828 return out
829 }
830
831 func classifyProviderPresetStatus(cfg *config.Config, preset config.ProviderPreset) (string, []string, []string) {
832 if cfg == nil {
833 return providerPresetStatusAvailable, nil, nil
834 }
835 installed := make([]string, 0)
836 missing := make([]string, 0)
837 modified := make([]string, 0)
838 conflicts := make([]string, 0)
839 similar := make([]string, 0)
840 presetID := strings.TrimSpace(preset.ID)
841 for _, entry := range preset.Entries {
842 name := strings.TrimSpace(entry.Name)
843 if name == "" {
844 continue
845 }
846 existing, ok := cfg.Provider(name)
847 if !ok {
848 missing = append(missing, name)
849 continue
850 }
851 if providerEntryCoreMatches(*existing, entry) {
852 installed = append(installed, name)
853 } else if providerEntryBelongsToPreset(*existing, preset, entry) {
854 modified = append(modified, name)
855 } else {
856 conflicts = append(conflicts, name)
857 }
858 }
859 if len(conflicts) > 0 {
860 return providerPresetStatusNameConflict, uniqueNonEmptyStrings(conflicts), uniqueNonEmptyStrings(missing)
861 }
862 if len(modified) > 0 {
863 return providerPresetStatusInstalledModified, uniqueNonEmptyStrings(modified), uniqueNonEmptyStrings(missing)
864 }
865 if len(installed) > 0 && len(missing) > 0 {
866 return providerPresetStatusPartial, uniqueNonEmptyStrings(installed), uniqueNonEmptyStrings(missing)
867 }
868 if len(installed) > 0 {
869 return providerPresetStatusInstalled, uniqueNonEmptyStrings(installed), nil
870 }
871 for i := range cfg.Providers {
872 existing := cfg.Providers[i]
873 existingName := strings.TrimSpace(existing.Name)
874 if existingName == "" {
875 continue
876 }
877 for _, entry := range preset.Entries {
878 if existingName == strings.TrimSpace(entry.Name) {
879 continue
880 }
881 if providerEntrySimilarToPreset(existing, entry, presetID) {
882 similar = append(similar, existingName)
883 break
884 }
885 }
886 }
887 if len(similar) > 0 {
888 return providerPresetStatusSimilarExisting, uniqueNonEmptyStrings(similar), nil
889 }
890 return providerPresetStatusAvailable, nil, nil
891 }
892
893 func providerEntrySimilarToPreset(existing, preset config.ProviderEntry, presetID string) bool {
894 if providerEntryUsesPresetID(existing, presetID) {
895 return true
896 }
897 return providerEntryCoreMatches(existing, preset)
898 }
899
900 func providerEntryUsesPresetID(existing config.ProviderEntry, presetID string) bool {
901 presetID = strings.TrimSpace(presetID)
902 return presetID != "" && strings.TrimSpace(existing.PresetID) == presetID
903 }
904
905 func providerEntryBelongsToPreset(existing config.ProviderEntry, preset config.ProviderPreset, entry config.ProviderEntry) bool {
906 if providerEntryUsesPresetID(existing, preset.ID) {
907 return true
908 }
909 // The recommended OpenCode Go bundle was introduced after the individual
910 // route presets. Treat a modified legacy route as part of the bundle so the
911 // one-step installer can preserve it and add only the missing routes.
912 return strings.TrimSpace(preset.ID) == "opencode-go-recommended" &&
913 strings.TrimSpace(existing.PresetID) == strings.TrimSpace(entry.Name)
914 }
915
916 func providerEntryCoreMatches(existing, preset config.ProviderEntry) bool {
917 return strings.EqualFold(strings.TrimSpace(existing.Kind), strings.TrimSpace(preset.Kind)) &&
918 normalizeProviderURL(existing.BaseURL) == normalizeProviderURL(preset.BaseURL) &&
919 strings.TrimSpace(existing.ChatURL) == strings.TrimSpace(preset.ChatURL) &&
920 strings.TrimSpace(existing.RequestURL) == strings.TrimSpace(preset.RequestURL) &&
921 existing.AuthHeader == preset.AuthHeader
922 }
923
924 func normalizeProviderURL(raw string) string {
925 raw = strings.TrimSpace(raw)
926 if raw == "" {
927 return ""
928 }
929 u, err := url.Parse(raw)
930 if err == nil && u.Scheme != "" && u.Host != "" {
931 u.Scheme = strings.ToLower(u.Scheme)
932 u.Host = strings.ToLower(u.Host)
933 u.Path = strings.TrimRight(u.Path, "/")
934 u.RawPath = ""
935 u.RawQuery = ""
936 u.Fragment = ""
937 return strings.TrimRight(u.String(), "/")
938 }
939 return strings.TrimRight(raw, "/")
940 }
941
942 func uniqueNonEmptyStrings(in []string) []string {
943 if len(in) == 0 {
944 return nil
945 }
946 out := make([]string, 0, len(in))
947 seen := map[string]bool{}
948 for _, s := range in {
949 s = strings.TrimSpace(s)
950 if s == "" || seen[s] {
951 continue
952 }
953 seen[s] = true
954 out = append(out, s)
955 }
956 return out
957 }
958
959 func officialProviderAddedSet(cfg *config.Config) map[string]bool {
960 out := map[string]bool{}
961 if cfg == nil {
962 return out
963 }
964 access := providerAccessSet(cfg.Desktop.ProviderAccess)
965 for i := range cfg.Providers {
966 p := cfg.Providers[i]
967 if !access[p.Name] {
968 continue
969 }
970 if kind := officialProviderKindFromEntry(p); kind != "" {
971 out[kind] = true
972 }
973 }
974 return out
975 }
976
977 // DesktopStartupSettings returns startup chrome preferences without provider/key state.
978 func (a *App) DesktopStartupSettings() (view DesktopStartupSettingsView) {
979 revision := a.nextConfigLoadWarningsRevision()
980 defer func() { view.ConfigWarningsRevision = revision }()
981 // Prefer the resilient workspace load so config warnings surface on first paint.
982 if cfg, err := config.LoadForRootReadOnly(a.activeWorkspaceRoot()); err == nil {
983 view = desktopStartupSettingsFromConfig(cfg)
984 view.ConfigWarnings = cfg.LoadWarnings()
985 view.ConfigPath = config.UserConfigPath()
986 return view
987 }
988 cfg, path, err := a.loadDesktopUserConfigForView()
989 if err != nil {
990 view = desktopStartupSettingsFromConfig(nil)
991 view.ConfigWarnings = []string{
992 "user configuration could not be loaded; using built-in defaults. Run: reasonix doctor repair",
993 }
994 view.ConfigPath = config.UserConfigPath()
995 return view
996 }
997 view = desktopStartupSettingsFromConfig(cfg)
998 view.ConfigPath = path
999 return view
1000 }
1001
1002 // OpenUserConfigPath reveals the user config file in the system file manager.
1003 func (a *App) OpenUserConfigPath() error {
1004 path := config.UserConfigPath()
1005 if path == "" {
1006 return fmt.Errorf("user config path is unavailable")
1007 }
1008 // Reveal the parent directory when the file does not exist yet so the user
1009 // can still find where config.toml should live.
1010 if _, err := os.Stat(path); err != nil {
1011 return a.RevealPath(filepath.Dir(path))
1012 }
1013 return a.RevealPath(path)
1014 }
1015
1016 // ReloadUserConfig reloads configuration for the active workspace after the
1017 // user fixes a broken file. Non-fatal load warnings remain visible when present.
1018 func (a *App) ReloadUserConfig() (DesktopStartupSettingsView, error) {
1019 return a.DesktopStartupSettings(), nil
1020 }
1021
1022 // Settings returns the current configuration for the Settings panel.
1023 func (a *App) Settings() SettingsView {
1024 cfg, cfgPath, err := a.loadDesktopUserConfigForView()
1025 if err != nil {
1026 return a.defaultSettingsView()
1027 }
1028 root := a.activeWorkspaceRoot()
1029 writeRoots := cfg.WriteRootsForRoot(root)
1030 effectiveWorkspaceRoot := ""
1031 if len(writeRoots) > 0 {
1032 effectiveWorkspaceRoot = writeRoots[0]
1033 }
1034 ctrl := a.activeCtrl()
1035 v := SettingsView{
1036 ModelSettingsFingerprint: modelSettingsEditFingerprint(cfg),
1037 DefaultModel: cfg.DefaultModel,
1038 PlannerModel: cfg.Agent.PlannerModel,
1039 VisionModel: cfg.Agent.VisionModel,
1040 WebSearchModel: cfg.Agent.WebSearchModel,
1041 WebSearchModels: []string{},
1042 SubagentModel: cfg.Agent.SubagentModel,
1043 SubagentEffort: cfg.Agent.SubagentEffort,
1044 AutoPlan: "off", // deprecated JSON compatibility for older frontends
1045 Providers: []ProviderView{},
1046 OfficialProviders: []ProviderView{},
1047 ProviderPresets: []ProviderPresetView{},
1048 Permissions: PermissionsView{
1049 Mode: orDefault(cfg.Permissions.Mode, "ask"),
1050 Allow: nonNil(cfg.Permissions.Allow),
1051 Ask: nonNil(cfg.Permissions.Ask),
1052 Deny: nonNil(cfg.Permissions.Deny),
1053 },
1054 Sandbox: a.sandboxViewFor(cfg, ctrl, writeRoots, effectiveWorkspaceRoot),
1055 Network: NetworkView{
1056 ProxyMode: cfg.NetworkProxyMode(),
1057 ProxyURL: cfg.Network.ProxyURL,
1058 NoProxy: cfg.Network.NoProxy,
1059 Proxy: NetworkProxyView{
1060 Type: orDefault(cfg.Network.Proxy.Type, "socks5"),
1061 Server: cfg.Network.Proxy.Server,
1062 Port: cfg.Network.Proxy.Port,
1063 Username: cfg.Network.Proxy.Username,
1064 Password: cfg.Network.Proxy.Password,
1065 },
1066 },
1067 Agent: AgentView{
1068 Temperature: cfg.Agent.Temperature,
1069 MaxSteps: cfg.Agent.MaxSteps,
1070 PlannerMaxSteps: cfg.Agent.PlannerMaxSteps,
1071 MaxSubagentDepth: desktopMaxSubagentDepth(cfg.Agent.MaxSubagentDepth),
1072 MaxSubagentConcurrency: desktopSubagentConcurrency(cfg.Agent.MaxSubagentConcurrency),
1073 MaxParallelWriters: desktopParallelWriters(cfg.Agent.MaxParallelWriters, cfg.Agent.MaxSubagentConcurrency),
1074 SystemPrompt: cfg.Agent.SystemPrompt,
1075 ReasoningLanguage: cfg.ReasoningLanguage(),
1076 CompactRatio: cfg.Agent.CompactRatio,
1077 EffectiveCompactRatio: cfg.Agent.CompactRatio,
1078 },
1079 Bot: botSettingsView(cfg.Bot),
1080 DesktopLanguage: cfg.DesktopLanguage(),
1081 DesktopCurrency: cfg.DesktopCurrency(),
1082 DesktopLayoutStyle: cfg.DesktopLayoutStyle(),
1083 DesktopTheme: cfg.DesktopTheme(),
1084 DesktopThemeStyle: cfg.DesktopThemeStyle(),
1085 DesktopTerminalTheme: cfg.DesktopTerminalTheme(),
1086 CloseBehavior: cfg.DesktopCloseBehavior(),
1087 DisplayMode: cfg.DesktopDisplayMode(),
1088 SessionExperience: cfg.DesktopSessionExperience(),
1089 ReasoningDisplayMode: cfg.DesktopReasoningDisplayMode(),
1090 ReasoningDisplayModeExplicit: cfg.DesktopReasoningDisplayModeExplicit(),
1091 StatusBarStyle: cfg.DesktopStatusBarStyle(),
1092 StatusBarItems: cfg.DesktopStatusBarItems(),
1093 DefaultToolApprovalMode: cfg.DesktopDefaultToolApprovalMode(),
1094 CheckUpdates: cfg.DesktopCheckUpdates(),
1095 UpdaterEnabled: desktopUpdaterEnabled(),
1096 UpdateChannel: cfg.DesktopUpdateChannel(),
1097 Telemetry: cfg.DesktopTelemetry(),
1098 Metrics: cfg.DesktopMetrics(),
1099 ExpandThinking: cfg.Desktop.ExpandThinking,
1100 ConversationWidth: cfg.DesktopConversationWidth(),
1101 ConfigPath: cfgPath,
1102 ShadowedByPath: shadowingConfigPath(cfgPath, root),
1103 ProviderKinds: nonNil(provider.Kinds()),
1104 AutoApproveTools: ctrl != nil && ctrl.AutoApproveTools(),
1105 Bypass: ctrl != nil && ctrl.AutoApproveTools(),
1106 }
1107 if ctrl != nil {
1108 if effective := ctrl.CompactRatio(); effective > 0 {
1109 v.Agent.EffectiveCompactRatio = effective
1110 v.Agent.CompactRatioOverridden = math.Abs(effective-v.Agent.CompactRatio) > 0.0001
1111 }
1112 }
1113 a.populateWebSearchSettings(&v, cfg, root)
1114 added := providerAccessSet(cfg.Desktop.ProviderAccess)
1115 resolver := config.NewCredentialResolverForRoot(root)
1116 credentialsRevision := providerCredentialsRevision()
1117 v.OfficialProviders = officialProviderViewsForRootWithResolver(officialProviderAddedSet(cfg), a.desktopOfficialPricingLanguage(cfg), root, resolver)
1118 v.ProviderPresets = providerPresetViewsForRootWithResolver(cfg, root, resolver)
1119 for i := range cfg.Providers {
1120 p := &cfg.Providers[i]
1121 providerView := providerViewFromEntryForRootWithResolverAndCredentials(*p, isOfficialBuiltInProvider(*p), added[p.Name], root, resolver, credentialsRevision)
1122 providerView.RecommendedUpgradeAvailable = providerView.RecommendedUpgradeAvailable && config.CanUpgradeDeepSeekProviderProtocolUserConfig(p.Name)
1123 v.Providers = append(v.Providers, providerView)
1124 }
1125 return v
1126 }
1127
1128 func botSettingsView(b config.BotConfig) BotSettingsView {
1129 mode := strings.TrimSpace(b.Feishu.Mode)
1130 if mode == "" {
1131 mode = "webhook"
1132 }
1133 return BotSettingsView{
1134 Enabled: b.Enabled,
1135 Model: b.Model,
1136 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode),
1137 MaxSteps: b.MaxSteps,
1138 DebounceMs: b.DebounceMs,
1139 QueueMode: b.QueueMode,
1140 QueueCap: b.QueueCap,
1141 QueueDrop: b.QueueDrop,
1142 IgnoreSelfMessages: b.IgnoreSelfMessages,
1143 SelfUserIDs: BotSelfUserIDsView{
1144 QQ: nonNil(b.SelfUserIDs.QQ),
1145 Feishu: nonNil(b.SelfUserIDs.Feishu),
1146 Weixin: nonNil(b.SelfUserIDs.Weixin),
1147 Dingtalk: nonNil(b.SelfUserIDs.Dingtalk),
1148 },
1149 Control: BotControlView{
1150 Enabled: b.Control.Enabled,
1151 Addr: b.Control.Addr,
1152 TokenEnv: b.Control.TokenEnv,
1153 },
1154 Pairing: BotPairingView{
1155 Enabled: b.Pairing.Enabled,
1156 RequestTTLMinutes: b.Pairing.RequestTTLMinutes,
1157 MaxPendingPerPlatform: b.Pairing.MaxPendingPerPlatform,
1158 },
1159 Routes: botRouteViews(b.Routes),
1160 Allowlist: BotAllowlistView{
1161 Enabled: b.Allowlist.Enabled,
1162 AllowAll: b.Allowlist.AllowAll,
1163 QQUsers: nonNil(b.Allowlist.QQUsers),
1164 FeishuUsers: nonNil(b.Allowlist.FeishuUsers),
1165 WeixinUsers: nonNil(b.Allowlist.WeixinUsers),
1166 QQApprovers: nonNil(b.Allowlist.QQApprovers),
1167 FeishuApprovers: nonNil(b.Allowlist.FeishuApprovers),
1168 WeixinApprovers: nonNil(b.Allowlist.WeixinApprovers),
1169 QQAdmins: nonNil(b.Allowlist.QQAdmins),
1170 FeishuAdmins: nonNil(b.Allowlist.FeishuAdmins),
1171 WeixinAdmins: nonNil(b.Allowlist.WeixinAdmins),
1172 QQGroups: nonNil(b.Allowlist.QQGroups),
1173 FeishuGroups: nonNil(b.Allowlist.FeishuGroups),
1174 WeixinGroups: nonNil(b.Allowlist.WeixinGroups),
1175 DingtalkUsers: nonNil(b.Allowlist.DingtalkUsers),
1176 DingtalkApprovers: nonNil(b.Allowlist.DingtalkApprovers),
1177 DingtalkAdmins: nonNil(b.Allowlist.DingtalkAdmins),
1178 DingtalkGroups: nonNil(b.Allowlist.DingtalkGroups),
1179 },
1180 QQ: QQBotView{
1181 Enabled: b.QQ.Enabled,
1182 AppID: b.QQ.AppID,
1183 AppSecretEnv: b.QQ.AppSecretEnv,
1184 SecretSet: strings.TrimSpace(b.QQ.AppSecretEnv) != "" && os.Getenv(b.QQ.AppSecretEnv) != "",
1185 Sandbox: b.QQ.Sandbox,
1186 Model: b.QQ.Model,
1187 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.QQ.ToolApprovalMode),
1188 WorkspaceRoot: b.QQ.WorkspaceRoot,
1189 Access: botAccessViewFromConfig(b.QQ.Access),
1190 },
1191 Feishu: FeishuBotView{
1192 Enabled: b.Feishu.Enabled,
1193 Domain: orDefault(strings.TrimSpace(b.Feishu.Domain), "feishu"),
1194 AppID: b.Feishu.AppID,
1195 AppSecretEnv: b.Feishu.AppSecretEnv,
1196 SecretSet: strings.TrimSpace(b.Feishu.AppSecretEnv) != "" && os.Getenv(b.Feishu.AppSecretEnv) != "",
1197 VerificationToken: b.Feishu.VerificationToken,
1198 Mode: mode,
1199 WebhookPort: b.Feishu.WebhookPort,
1200 RequireMention: b.Feishu.RequireMention,
1201 },
1202 Weixin: WeixinBotView{
1203 Enabled: b.Weixin.Enabled,
1204 AccountID: b.Weixin.AccountID,
1205 TokenEnv: b.Weixin.TokenEnv,
1206 TokenSet: strings.TrimSpace(b.Weixin.TokenEnv) != "" && os.Getenv(b.Weixin.TokenEnv) != "",
1207 APIBase: b.Weixin.APIBase,
1208 },
1209 Dingtalk: DingtalkBotView{
1210 Enabled: b.Dingtalk.Enabled,
1211 ClientID: b.Dingtalk.ClientID,
1212 ClientSecretEnv: b.Dingtalk.SecretEnv,
1213 SecretSet: (strings.TrimSpace(b.Dingtalk.SecretEnv) != "" && os.Getenv(b.Dingtalk.SecretEnv) != "") || strings.TrimSpace(b.Dingtalk.ClientSecret) != "",
1214 BotName: b.Dingtalk.BotName,
1215 RequireMention: b.Dingtalk.RequireMention,
1216 Model: strings.TrimSpace(b.Dingtalk.Model),
1217 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.Dingtalk.ToolApprovalMode),
1218 WorkspaceRoot: strings.TrimSpace(b.Dingtalk.WorkspaceRoot),
1219 Access: botAccessViewFromConfig(b.Dingtalk.Access),
1220 },
1221 Connections: botConnectionViews(b.Connections),
1222 }
1223 }
1224
1225 func orDefault(s, def string) string {
1226 if strings.TrimSpace(s) == "" {
1227 return def
1228 }
1229 return s
1230 }
1231
1232 func botRouteViews(routes []config.BotRouteConfig) []BotRouteView {
1233 if len(routes) == 0 {
1234 return []BotRouteView{}
1235 }
1236 out := make([]BotRouteView, 0, len(routes))
1237 for _, route := range routes {
1238 out = append(out, BotRouteView{
1239 ConnectionID: route.ConnectionID,
1240 Platform: route.Platform,
1241 ChatType: route.ChatType,
1242 ChatID: route.ChatID,
1243 UserID: route.UserID,
1244 ThreadID: route.ThreadID,
1245 Model: route.Model,
1246 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(route.ToolApprovalMode),
1247 WorkspaceRoot: route.WorkspaceRoot,
1248 })
1249 }
1250 return out
1251 }
1252
1253 func botRouteConfigs(routes []BotRouteView) []config.BotRouteConfig {
1254 if len(routes) == 0 {
1255 return nil
1256 }
1257 out := make([]config.BotRouteConfig, 0, len(routes))
1258 for _, route := range routes {
1259 cfg := config.BotRouteConfig{
1260 ConnectionID: strings.TrimSpace(route.ConnectionID),
1261 Platform: strings.TrimSpace(route.Platform),
1262 ChatType: strings.TrimSpace(route.ChatType),
1263 ChatID: strings.TrimSpace(route.ChatID),
1264 UserID: strings.TrimSpace(route.UserID),
1265 ThreadID: strings.TrimSpace(route.ThreadID),
1266 Model: strings.TrimSpace(route.Model),
1267 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(route.ToolApprovalMode),
1268 WorkspaceRoot: strings.TrimSpace(route.WorkspaceRoot),
1269 }
1270 if cfg.ConnectionID == "" && cfg.Platform == "" && cfg.ChatType == "" && cfg.ChatID == "" && cfg.UserID == "" && cfg.ThreadID == "" &&
1271 cfg.Model == "" && cfg.ToolApprovalMode == "" && cfg.WorkspaceRoot == "" {
1272 continue
1273 }
1274 out = append(out, cfg)
1275 }
1276 if len(out) == 0 {
1277 return nil
1278 }
1279 return out
1280 }
1281
1282 func botAccessViewFromConfig(access config.BotAccessConfig) BotAccessView {
1283 return BotAccessView{
1284 Enabled: access.Enabled,
1285 AllowAll: access.AllowAll,
1286 PairingEnabled: access.PairingEnabled,
1287 Users: nonNil(access.Users),
1288 Groups: nonNil(access.Groups),
1289 Approvers: nonNil(access.Approvers),
1290 Admins: nonNil(access.Admins),
1291 }
1292 }
1293
1294 func botAccessConfigFromView(access BotAccessView) config.BotAccessConfig {
1295 return config.BotAccessConfig{
1296 Enabled: access.Enabled,
1297 AllowAll: access.AllowAll,
1298 PairingEnabled: access.PairingEnabled,
1299 Users: trimList(access.Users),
1300 Groups: trimList(access.Groups),
1301 Approvers: trimList(access.Approvers),
1302 Admins: trimList(access.Admins),
1303 }
1304 }
1305
1306 func botDomainOrDefault(domain string) string {
1307 if strings.EqualFold(strings.TrimSpace(domain), "lark") {
1308 return "lark"
1309 }
1310 return "feishu"
1311 }
1312
1313 // apply (write config, then rebuild the controller so it's live)
1314
1315 // applyConfigChange mutates the user-global config and rebuilds the controller so
1316 // the change takes effect this session. Desktop settings such as providers and
1317 // keys are account-level, not per-project: writing them to the global config
1318 // rather than the cwd's reasonix.toml is what lets them survive a workspace switch.
1319 func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
1320 _, err := a.applyConfigChangeWithWarning("settings", mutate)
1321 return err
1322 }
1323
1324 // applySkillConfigChange edits the config file that owns the selected [skills]
1325 // field. Project skill settings shadow the global setting at runtime, so
1326 // writing only the user config would make the UI appear to save while the
1327 // active project continued using its old value.
1328 func (a *App) applySkillConfigChange(field, setting string, mutate func(*config.Config) error) error {
1329 return a.applySkillConfigChangeForFields([]string{field}, setting, mutate)
1330 }
1331
1332 func (a *App) applySkillConfigChangeForFields(fields []string, setting string, mutate func(*config.Config) error) error {
1333 workspaceRoot := a.activeWorkspaceRoot()
1334 projectPath := config.SourcePathForRoot(workspaceRoot)
1335 projectOwned := strings.TrimSpace(projectPath) != "" && !config.IsUserConfigPath(projectPath)
1336 if projectOwned {
1337 projectOwned = slices.ContainsFunc(fields, func(field string) bool {
1338 return config.ConfigFileDefinesSkillKey(projectPath, field)
1339 })
1340 }
1341 if !projectOwned {
1342 return a.applyConfigChange(mutate)
1343 }
1344 if err := a.ensureActiveTabRebuildAllowed(setting); err != nil {
1345 return err
1346 }
1347 if err := func() error {
1348 unlock, err := config.LockConfigFileEdits(projectPath)
1349 if err != nil {
1350 return err
1351 }
1352 defer unlock()
1353 cfg, err := config.LoadForEditWithoutCredentialsReadOnlyStrict(projectPath)
1354 if err != nil {
1355 return err
1356 }
1357 if err := mutate(cfg); err != nil {
1358 return err
1359 }
1360 for _, field := range fields {
1361 if err := cfg.KeepProjectSkillKey(field); err != nil {
1362 return err
1363 }
1364 }
1365 return cfg.SaveTo(projectPath)
1366 }(); err != nil {
1367 return err
1368 }
1369 if err := a.rebuildSetting(setting); err != nil {
1370 if _, ok := a.deferredRebuildWarning(setting, err); ok {
1371 return nil
1372 }
1373 return err
1374 }
1375 return nil
1376 }
1377
1378 func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.Config) error) (string, error) {
1379 return a.applyConfigChangeWithSave(setting, mutate, func(c *config.Config, path string) error { return c.SaveTo(path) })
1380 }
1381
1382 func (a *App) applyConfigChangeWithSave(setting string, mutate func(*config.Config) error, save func(*config.Config, string) error) (string, error) {
1383 if err := a.ensureActiveTabRebuildAllowed(setting); err != nil {
1384 return "", err
1385 }
1386 if err := func() error {
1387 // Serialize the load-modify-save against other in-process config editors
1388 // (bot auto-session persistence, applyConfigOnly) so neither drops the
1389 // other's fields. rebuild() runs after unlocking — it does slow work and
1390 // must not hold the config edit lock.
1391 unlock := config.LockUserConfigEdits()
1392 defer unlock()
1393 cfg, path, err := a.loadDesktopUserConfigForEdit()
1394 if err != nil {
1395 return err
1396 }
1397 if err := mutate(cfg); err != nil {
1398 return err
1399 }
1400 return save(cfg, path)
1401 }(); err != nil {
1402 return "", err
1403 }
1404 if err := a.rebuildSetting(setting); err != nil {
1405 if warning, ok := a.deferredRebuildWarning(setting, err); ok {
1406 a.refreshActiveTabMetaExtras()
1407 return warning, nil
1408 }
1409 return "", err
1410 }
1411 a.refreshActiveTabMetaExtras()
1412 return "", nil
1413 }
1414
1415 // refreshActiveTabMetaExtras invalidates the cached model capability snapshot
1416 // after a settings rebuild. In particular, changing Agent.VisionModel should
1417 // immediately suppress the text-only image warning in the composer instead of
1418 // waiting for the normal metadata cache TTL.
1419 func (a *App) refreshActiveTabMetaExtras() {
1420 if tab := a.activeTab(); tab != nil {
1421 a.scheduleTabMetaExtrasRefresh(tab.ID)
1422 }
1423 }
1424
1425 func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
1426 unlock := config.LockUserConfigEdits()
1427 defer unlock()
1428 cfg, path, err := a.loadDesktopUserConfigForEdit()
1429 if err != nil {
1430 return err
1431 }
1432 if err := mutate(cfg); err != nil {
1433 return err
1434 }
1435 return cfg.SaveTo(path)
1436 }
1437
1438 func (a *App) ensureActiveTabRebuildAllowed(setting string) error {
1439 tab := a.activeTab()
1440 if tab == nil {
1441 if a.ctx == nil {
1442 return nil
1443 }
1444 return fmt.Errorf("no active tab")
1445 }
1446 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
1447 return err
1448 }
1449 return nil
1450 }
1451
1452 func (a *App) ensureLiveControllersRuntimeMutationAllowed(setting string) error {
1453 a.mu.RLock()
1454 defer a.mu.RUnlock()
1455 for _, tab := range a.tabs {
1456 if tab == nil {
1457 continue
1458 }
1459 if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, setting); err != nil {
1460 return err
1461 }
1462 }
1463 return nil
1464 }
1465
1466 func (a *App) deferredRebuildWarning(setting string, err error) (string, bool) {
1467 return a.deferredRebuildWarningForTab(setting, err, a.activeTab())
1468 }
1469
1470 func (a *App) deferredRebuildWarningForTab(setting string, err error, tab *WorkspaceTab) (string, bool) {
1471 if err == nil || !errors.Is(err, agent.ErrSessionLeaseHeld) {
1472 return "", false
1473 }
1474 setting = strings.TrimSpace(setting)
1475 if setting == "" {
1476 setting = "settings"
1477 }
1478 userErr := userFacingSessionLeaseError(setting, err)
1479 warning := fmt.Sprintf("%s saved, but the current session could not refresh yet: %s", setting, userErr.Error())
1480 slog.Warn("desktop: deferred settings rebuild", "setting", setting, "err", err)
1481 // Bind both the warning and the retry to the tab whose refresh failed, so a
1482 // tab switch or a multi-tab mutation cannot misroute either one.
1483 if tab != nil {
1484 a.warnForTab(tab.ID, warning)
1485 a.scheduleDeferredRebuild(tab.ID, setting)
1486 }
1487 return warning, true
1488 }
1489
1490 // loadDesktopUserConfigForEdit loads the user config for a write path. Pending
1491 // legacy migrations are assembled in memory and reach disk through the locked
1492 // user-config save, never by rewriting a project file as a side effect.
1493 //
1494 // Contract: the caller must already hold config.LockUserConfigEdits() across
1495 // its whole load→mutate→SaveTo cycle, so the migration write-back cannot race
1496 // other in-process config editors. This helper must never acquire that lock
1497 // itself: applyConfigChange/applyConfigOnly (and every other caller) invoke it
1498 // with the lock held, so an inner acquire would self-deadlock. Read-only
1499 // callers must use loadDesktopUserConfigForView (or its WithCredentials
1500 // variant), which never writes to disk.
1501 func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
1502 return a.loadDesktopUserConfigForEditForRoot(a.activeWorkspaceRoot())
1503 }
1504
1505 func (a *App) loadDesktopUserConfigForEditForRoot(root string) (*config.Config, string, error) {
1506 userPath := config.UserConfigPath()
1507 if userPath == "" {
1508 return nil, "", fmt.Errorf("cannot resolve user config directory")
1509 }
1510 if _, err := os.Stat(userPath); err == nil {
1511 cfg, err := config.LoadForEditReadOnlyStrict(userPath)
1512 if err != nil {
1513 return nil, "", err
1514 }
1515 if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
1516 return nil, "", err
1517 }
1518 if err := a.migrateLegacyBotConfigToUserForRoot(root, cfg, userPath); err != nil {
1519 return nil, "", err
1520 }
1521 return cfg, userPath, nil
1522 }
1523 cfg, err := config.LoadForEditReadOnlyStrict(userPath)
1524 if err != nil {
1525 return nil, "", err
1526 }
1527 legacyPath := config.SourcePathForRoot(root)
1528 if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
1529 if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
1530 return nil, "", err
1531 }
1532 return cfg, userPath, nil
1533 }
1534 legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
1535 if err != nil {
1536 return nil, "", err
1537 }
1538 normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
1539 legacyCfg.ConfigVersion = config.Default().ConfigVersion
1540 if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil {
1541 return nil, "", err
1542 }
1543 return legacyCfg, userPath, nil
1544 }
1545
1546 // loadDesktopUserConfigForView loads the user config for read-only callers.
1547 // Contract: it never writes to disk, so it is safe without
1548 // config.LockUserConfigEdits(). Legacy migrations (provider-access normalize,
1549 // legacy bot-config merge) are applied to the returned copy in memory only;
1550 // the on-disk file migrates the first time a locked write path runs
1551 // loadDesktopUserConfigForEdit. Credentials (Reasonix global .env) are not
1552 // loaded; callers that hand the config to a runtime resolving secrets from the
1553 // process env must use loadDesktopUserConfigForViewWithCredentials.
1554 func (a *App) loadDesktopUserConfigForView() (*config.Config, string, error) {
1555 return a.loadDesktopUserConfigForViewForRoot(a.activeWorkspaceRoot())
1556 }
1557
1558 func (a *App) loadDesktopUserConfigForViewForRoot(root string) (*config.Config, string, error) {
1559 return a.loadDesktopUserConfigReadOnlyForRoot(root, config.LoadForEditWithoutCredentialsReadOnlyStrict)
1560 }
1561
1562 // loadDesktopUserConfigForViewWithCredentials is loadDesktopUserConfigForView
1563 // plus credential resolution: like config.LoadForEdit it loads Reasonix's
1564 // global .env into the process env. Use it for read-only loads whose result
1565 // feeds a runtime that resolves env-based secrets — the bot runtime
1566 // (app-secret/control-token envs) and MCP server connects. It still never
1567 // writes to disk.
1568 func (a *App) loadDesktopUserConfigForViewWithCredentials() (*config.Config, string, error) {
1569 return a.loadDesktopUserConfigForViewWithCredentialsForRoot(a.activeWorkspaceRoot())
1570 }
1571
1572 func (a *App) loadDesktopUserConfigForViewWithCredentialsForRoot(root string) (*config.Config, string, error) {
1573 return a.loadDesktopUserConfigReadOnlyForRoot(root, config.LoadForEditReadOnlyStrict)
1574 }
1575
1576 // loadDesktopUserConfigReadOnlyForRoot is the shared pure-read loader behind
1577 // the View variants: same shape as loadDesktopUserConfigForEdit, but every
1578 // legacy migration stays in memory (zero SaveTo) and resolves from root.
1579 func (a *App) loadDesktopUserConfigReadOnlyForRoot(root string, load func(string) (*config.Config, error)) (*config.Config, string, error) {
1580 userPath := config.UserConfigPath()
1581 if userPath == "" {
1582 return nil, "", fmt.Errorf("cannot resolve user config directory")
1583 }
1584 if _, err := os.Stat(userPath); err == nil {
1585 cfg, err := load(userPath)
1586 if err != nil {
1587 return nil, "", err
1588 }
1589 normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
1590 legacyPath := config.SourcePathForRoot(root)
1591 if legacyPath != "" && !sameConfigPath(legacyPath, userPath) {
1592 legacyCfg, err := load(legacyPath)
1593 if err != nil {
1594 return nil, "", err
1595 }
1596 mergeLegacyBotConfigInMemory(cfg, legacyCfg)
1597 }
1598 return cfg, userPath, nil
1599 }
1600 cfg, err := load(userPath)
1601 if err != nil {
1602 return nil, "", err
1603 }
1604 legacyPath := config.SourcePathForRoot(root)
1605 if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
1606 normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
1607 return cfg, userPath, nil
1608 }
1609 // The user config does not exist yet: serve the legacy config as the view.
1610 // It already carries any legacy bot config, so no merge is needed; the
1611 // write path creates the migrated user file later.
1612 legacyCfg, err := load(legacyPath)
1613 if err != nil {
1614 return nil, "", err
1615 }
1616 normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
1617 legacyCfg.ConfigVersion = config.Default().ConfigVersion
1618 return legacyCfg, userPath, nil
1619 }
1620
1621 // migrateLegacyBotConfigToUserForRoot is the write-path legacy bot-config
1622 // migration against an explicit workspace's legacy config file. Callers must
1623 // hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit).
1624 func (a *App) migrateLegacyBotConfigToUserForRoot(root string, userCfg *config.Config, userPath string) error {
1625 if userCfg == nil {
1626 return nil
1627 }
1628 legacyPath := config.SourcePathForRoot(root)
1629 if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
1630 return nil
1631 }
1632 legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
1633 if err != nil {
1634 return err
1635 }
1636 return migrateLegacyBotConfigToUser(userCfg, legacyCfg, userPath)
1637 }
1638
1639 // migrateLegacyBotConfigToUser is the write-path variant: it merges the legacy
1640 // bot config in memory and persists the result to userPath. Callers must hold
1641 // config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read paths
1642 // use mergeLegacyBotConfigInMemory instead.
1643 func migrateLegacyBotConfigToUser(userCfg, legacyCfg *config.Config, userPath string) error {
1644 if !mergeLegacyBotConfigInMemory(userCfg, legacyCfg) {
1645 return nil
1646 }
1647 if err := userCfg.SaveTo(userPath); err != nil {
1648 return fmt.Errorf("migrate legacy bot config: %w", err)
1649 }
1650 return nil
1651 }
1652
1653 // mergeLegacyBotConfigInMemory copies the legacy bot config onto userCfg when
1654 // the user config has none of its own. It never touches disk; it reports
1655 // whether userCfg changed (i.e. whether a write path should persist it).
1656 func mergeLegacyBotConfigInMemory(userCfg, legacyCfg *config.Config) bool {
1657 if userCfg == nil || legacyCfg == nil || desktopBotConfigConfigured(userCfg.Bot) {
1658 return false
1659 }
1660 if !desktopBotConfigConfigured(legacyCfg.Bot) {
1661 return false
1662 }
1663 userCfg.Bot = legacyCfg.Bot
1664 return true
1665 }
1666
1667 func desktopBotConfigConfigured(bot config.BotConfig) bool {
1668 defaults := config.Default().Bot
1669 if bot.Enabled || strings.TrimSpace(bot.Model) != "" || len(bot.Connections) > 0 {
1670 return true
1671 }
1672 if (bot.MaxSteps != 0 && bot.MaxSteps != defaults.MaxSteps) ||
1673 (bot.DebounceMs != 0 && bot.DebounceMs != defaults.DebounceMs) ||
1674 (strings.TrimSpace(bot.QueueMode) != "" && bot.QueueMode != defaults.QueueMode) ||
1675 (bot.QueueCap != 0 && bot.QueueCap != defaults.QueueCap) ||
1676 (strings.TrimSpace(bot.QueueDrop) != "" && bot.QueueDrop != defaults.QueueDrop) ||
1677 bot.IgnoreSelfMessages != defaults.IgnoreSelfMessages ||
1678 bot.Pairing.Enabled != defaults.Pairing.Enabled ||
1679 (bot.Pairing.RequestTTLMinutes != 0 && bot.Pairing.RequestTTLMinutes != defaults.Pairing.RequestTTLMinutes) ||
1680 (bot.Pairing.MaxPendingPerPlatform != 0 && bot.Pairing.MaxPendingPerPlatform != defaults.Pairing.MaxPendingPerPlatform) ||
1681 bot.Control.Enabled != defaults.Control.Enabled ||
1682 (strings.TrimSpace(bot.Control.Addr) != "" && bot.Control.Addr != defaults.Control.Addr) ||
1683 (strings.TrimSpace(bot.Control.TokenEnv) != "" && bot.Control.TokenEnv != defaults.Control.TokenEnv) ||
1684 len(bot.Routes) > 0 ||
1685 len(bot.SelfUserIDs.QQ)+len(bot.SelfUserIDs.Feishu)+len(bot.SelfUserIDs.Weixin)+len(bot.SelfUserIDs.Dingtalk) > 0 {
1686 return true
1687 }
1688 if bot.Allowlist.AllowAll ||
1689 len(bot.Allowlist.QQUsers)+len(bot.Allowlist.FeishuUsers)+len(bot.Allowlist.WeixinUsers)+len(bot.Allowlist.DingtalkUsers) > 0 ||
1690 len(bot.Allowlist.QQApprovers)+len(bot.Allowlist.FeishuApprovers)+len(bot.Allowlist.WeixinApprovers)+len(bot.Allowlist.DingtalkApprovers) > 0 ||
1691 len(bot.Allowlist.QQAdmins)+len(bot.Allowlist.FeishuAdmins)+len(bot.Allowlist.WeixinAdmins)+len(bot.Allowlist.DingtalkAdmins) > 0 ||
1692 len(bot.Allowlist.QQGroups)+len(bot.Allowlist.FeishuGroups)+len(bot.Allowlist.WeixinGroups)+len(bot.Allowlist.DingtalkGroups) > 0 {
1693 return true
1694 }
1695 if bot.QQ.Enabled ||
1696 strings.TrimSpace(bot.QQ.AppID) != "" ||
1697 bot.QQ.AppSecretEnv != defaults.QQ.AppSecretEnv ||
1698 bot.QQ.Sandbox != defaults.QQ.Sandbox ||
1699 strings.TrimSpace(bot.QQ.Model) != "" ||
1700 strings.TrimSpace(bot.QQ.ToolApprovalMode) != "" ||
1701 strings.TrimSpace(bot.QQ.WorkspaceRoot) != "" ||
1702 botruntime.BotAccessActive(bot.QQ.Access) {
1703 return true
1704 }
1705 if bot.Feishu.Enabled ||
1706 strings.TrimSpace(bot.Feishu.AppID) != "" ||
1707 bot.Feishu.Domain != defaults.Feishu.Domain ||
1708 bot.Feishu.AppSecretEnv != defaults.Feishu.AppSecretEnv ||
1709 strings.TrimSpace(bot.Feishu.VerificationToken) != "" ||
1710 bot.Feishu.Mode != defaults.Feishu.Mode ||
1711 bot.Feishu.WebhookPort != defaults.Feishu.WebhookPort ||
1712 bot.Feishu.RequireMention != defaults.Feishu.RequireMention {
1713 return true
1714 }
1715 if bot.Weixin.Enabled ||
1716 bot.Weixin.AccountID != defaults.Weixin.AccountID ||
1717 bot.Weixin.TokenEnv != defaults.Weixin.TokenEnv ||
1718 bot.Weixin.APIBase != defaults.Weixin.APIBase {
1719 return true
1720 }
1721 if bot.Dingtalk.Enabled ||
1722 strings.TrimSpace(bot.Dingtalk.ClientID) != "" ||
1723 strings.TrimSpace(bot.Dingtalk.ClientSecret) != "" ||
1724 strings.TrimSpace(bot.Dingtalk.ClientIDEnv) != "" ||
1725 strings.TrimSpace(bot.Dingtalk.SecretEnv) != "" ||
1726 strings.TrimSpace(bot.Dingtalk.BotName) != "" ||
1727 bot.Dingtalk.RequireMention != defaults.Dingtalk.RequireMention {
1728 return true
1729 }
1730 return false
1731 }
1732
1733 // normalizeLegacyDesktopProviderAccessForSettings is the write-path variant:
1734 // it normalizes in memory and persists the migrated form to path. Callers must
1735 // hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read
1736 // paths use normalizeLegacyDesktopProviderAccessInMemory instead.
1737 func normalizeLegacyDesktopProviderAccessForSettings(cfg *config.Config, path string) error {
1738 if !normalizeLegacyDesktopProviderAccessInMemory(cfg, path) {
1739 return nil
1740 }
1741 if _, err := os.Stat(path); err != nil {
1742 if os.IsNotExist(err) {
1743 return nil
1744 }
1745 return err
1746 }
1747 return cfg.SaveTo(path)
1748 }
1749
1750 // normalizeLegacyDesktopProviderAccessInMemory seeds cfg.Desktop.ProviderAccess
1751 // from configs written before Settings tracked explicit provider access. It
1752 // never touches disk; it reports whether cfg now carries a normalized list
1753 // that the file at path does not declare (i.e. whether a write path should
1754 // persist it).
1755 func normalizeLegacyDesktopProviderAccessInMemory(cfg *config.Config, path string) bool {
1756 if cfg == nil || len(cfg.Desktop.ProviderAccess) > 0 || configDeclaresProviderAccess(path) {
1757 return false
1758 }
1759 config.NormalizeLegacyDesktopProviderAccess(cfg)
1760 return len(cfg.Desktop.ProviderAccess) > 0 && strings.TrimSpace(path) != ""
1761 }
1762
1763 func configDeclaresProviderAccess(path string) bool {
1764 if strings.TrimSpace(path) == "" {
1765 return false
1766 }
1767 body, err := readFileUTF8(path)
1768 if err != nil {
1769 return false
1770 }
1771 for line := range strings.SplitSeq(string(body), "\n") {
1772 if before, _, ok := strings.Cut(line, "#"); ok {
1773 line = before
1774 }
1775 line = strings.TrimSpace(line)
1776 if after, ok := strings.CutPrefix(line, "provider_access"); ok {
1777 rest := strings.TrimSpace(after)
1778 return strings.HasPrefix(rest, "=")
1779 }
1780 }
1781 return false
1782 }
1783
1784 func (a *App) activeWorkspaceRoot() string {
1785 tab := a.activeTab()
1786 if tab != nil {
1787 a.reconcileTabWithPinnedSessionMeta(tab)
1788 if strings.TrimSpace(tab.WorkspaceRoot) != "" {
1789 return tab.WorkspaceRoot
1790 }
1791 }
1792 return "."
1793 }
1794
1795 func providerCredentialSourceNotice(apiKeyEnv, value string) string {
1796 return ""
1797 }
1798
1799 func sameConfigPath(a, b string) bool {
1800 a = strings.TrimSpace(a)
1801 b = strings.TrimSpace(b)
1802 if a == "" || b == "" {
1803 return false
1804 }
1805 aAbs, aErr := filepath.Abs(a)
1806 bAbs, bErr := filepath.Abs(b)
1807 if aErr == nil && bErr == nil {
1808 return filepath.Clean(aAbs) == filepath.Clean(bAbs)
1809 }
1810 return filepath.Clean(a) == filepath.Clean(b)
1811 }
1812
1813 // rebuild builds a replacement controller from the (just-changed) config and
1814 // swaps it in only after the target session lease is available. The old
1815 // controller stays usable if the rebuild fails.
1816 func (a *App) rebuild() error {
1817 return a.rebuildSetting("settings")
1818 }
1819
1820 func (a *App) rebuildSetting(setting string) error {
1821 if a.ctx == nil {
1822 return nil
1823 }
1824 // Serialize with SetModelForTab and the deferred-rebuild retry loop: two
1825 // concurrent build+swap sequences on the same tab leak the first-swapped
1826 // controller and double-close the old one.
1827 a.runtimeRebuildMu.Lock()
1828 err := a.rebuildSettingLocked(setting)
1829 a.runtimeRebuildMu.Unlock()
1830 return err
1831 }
1832
1833 // rebuildSettingLocked is rebuildSetting's body; callers must already hold
1834 // runtimeRebuildMu. The deferred-rebuild retry loop calls this directly because
1835 // it takes the lock across its lease probe.
1836 func (a *App) rebuildSettingLocked(setting string) error {
1837 if a.ctx == nil {
1838 return nil
1839 }
1840 tab := a.activeTab()
1841 if tab == nil {
1842 return fmt.Errorf("no active tab")
1843 }
1844 tab.turnStartMu.Lock()
1845 defer tab.turnStartMu.Unlock()
1846 return a.rebuildSettingTurnLocked(setting, tab, false, false)
1847 }
1848
1849 // rebuildSettingTurnLocked is rebuildSettingLocked's body; callers must hold
1850 // runtimeRebuildMu and the passed tab's turnStartMu. admissionHeld is true for
1851 // MCP lifecycle callers that also hold runtimeAdmissionMu's write side.
1852 // reload selects the stage-3b runtime-reload build path (boot.Rebuild migrates
1853 // the session) instead of the legacy boot.Build + manual migration; everything
1854 // else — active-work guards, workspace prep, lease moves, swap, close-after-
1855 // swap, fence — is shared.
1856 func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admissionHeld bool, reload bool) error {
1857 return a.rebuildSettingTurnLockedWithModel(setting, tab, "", admissionHeld, reload)
1858 }
1859
1860 // rebuildSettingTurnLockedWithModel optionally builds the replacement for a
1861 // target model without changing tab.model before the swap. Provider removal
1862 // uses this to remain failure-atomic: a failed fallback build leaves both the
1863 // old controller and its visible model identity untouched.
1864 func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTab, modelOverride string, admissionHeld bool, reload bool) error {
1865 if a.ctx == nil {
1866 return nil
1867 }
1868 pendingSequence := a.deferredRebuildSequence(tab.ID)
1869 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
1870 return err
1871 }
1872 if !admissionHeld {
1873 if err := a.ensureTabControllerWorkspace(tab); err != nil {
1874 return err
1875 }
1876 }
1877 prevPath := a.reconciledSessionPathForTab(tab)
1878 if prevPath == "" {
1879 prevPath = a.currentSessionPathFor(tab)
1880 }
1881 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
1882 prevPath = a.reconciledSessionPathForTab(tab)
1883 if prevPath == "" {
1884 prevPath = a.currentSessionPathFor(tab)
1885 }
1886 }
1887 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
1888 return err
1889 }
1890
1891 var carried []provider.Message
1892 oldCtrl := a.controllerForTab(tab)
1893 if oldCtrl != nil {
1894 if prevPath == "" {
1895 prevPath = oldCtrl.SessionPath()
1896 }
1897 if err := a.snapshotSettingsRebuildSource(tab, oldCtrl, prevPath, setting); err != nil {
1898 return err
1899 }
1900 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
1901 carried = oldCtrl.History()
1902 }
1903 snap := a.tabRuntimeSnapshot(tab)
1904 runtime := snap.normalizedRuntime()
1905 model := snap.model
1906 var modelConfig *config.Config
1907 if override := strings.TrimSpace(modelOverride); override != "" {
1908 model = override
1909 }
1910 if cfg, err := config.LoadForRoot(snap.workspaceRoot); err == nil {
1911 modelConfig = cfg
1912 if setting == "saved model settings" {
1913 model, err = resolveModelSettingsRuntime(cfg, model)
1914 if err != nil {
1915 return err
1916 }
1917 } else {
1918 if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
1919 if fallback && strings.TrimSpace(model) != "" {
1920 a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", model, resolved))
1921 }
1922 model = resolved
1923 }
1924 }
1925 }
1926 ctrl, restoredRuntime, path, err := a.buildSettingReplacementController(tab, snap, runtime, model, prevPath, setting, oldCtrl, carried, reload)
1927 if err != nil {
1928 if oldCtrl == nil {
1929 a.mu.Lock()
1930 leaseHeld, save := a.markTabStartupFailureLocked(tab, err, keepStartupRestore)
1931 a.mu.Unlock()
1932 a.writeTabsSaveRequest(save)
1933 if leaseHeld {
1934 a.scheduleDeferredStartupBuild(tab.ID)
1935 }
1936 a.emitReady(a.ctx)
1937 }
1938 return err
1939 }
1940 if err := validateModelSettingsReplacement(ctrl, oldCtrl); err != nil {
1941 return err
1942 }
1943 if err := a.runRebindCandidateHook("settings_before_authority"); err != nil {
1944 discardReplacementController(ctrl, oldCtrl)
1945 return err
1946 }
1947 a.mu.Lock()
1948 if err := a.authorizeTabReplacementLocked(tab, ctrl, "rebuilding settings", "rebuilt"); err != nil {
1949 a.mu.Unlock()
1950 discardReplacementController(ctrl, oldCtrl)
1951 tab.releaseSessionLease()
1952 return err
1953 }
1954 if err := activateReplacementController(oldCtrl, ctrl); err != nil {
1955 a.mu.Unlock()
1956 discardReplacementController(ctrl, oldCtrl)
1957 return fmt.Errorf("rebuilding settings: activate replacement runtime: %w", err)
1958 }
1959 tab.Ctrl = ctrl
1960 tab.modelApplication.failure = nil
1961 tab.effort = config.RebindSessionEffort(modelConfig, snap.model, model, snap.effort)
1962 tab.model = model
1963 tab.Label = ctrl.Label()
1964 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
1965 clearTabStartupError(tab)
1966 tab.Ready = true
1967 // Supersede any in-flight startup build: it would otherwise finish later,
1968 // pass its generation check, and overwrite the controller just installed.
1969 a.supersedeTabBuildLocked(tab)
1970 a.saveTabsLocked()
1971 a.mu.Unlock()
1972 // True subgraph rebuilds reuse the same controller pointer — never Close it.
1973 if oldCtrl != nil && oldCtrl != ctrl {
1974 retireReplacedController(oldCtrl, ctrl)
1975 }
1976 a.persistTabSessionPath(tab, path)
1977 a.clearDeferredRebuildVersion(tab.ID, pendingSequence)
1978 a.notifyTabRuntimeRebuilt(tab)
1979 a.emitReady(a.ctx)
1980 return nil
1981 }
1982
1983 // buildSettingReplacementController builds and migrates the replacement for rebuildSettingTurnLocked, returning the controller, restored runtime, and session path it
1984 // bound. reload=false is the legacy settings path (boot.Build plus the
1985 // desktop's manual migration); reload=true is the stage-3b runtime reload,
1986 // routing build and migration through boot.Rebuild so history, approval mode
1987 // and grants, plan/goal state, and lifecycle move inside the boot layer. The
1988 // caller owns the swap, closing the old controller after the swap, and the
1989 // post-swap persistence.
1990 func (a *App) buildSettingReplacementController(tab *WorkspaceTab, snap tabRuntimeSnapshot, runtime normalizedTabRuntime, model, prevPath, setting string, oldCtrl control.SessionAPI, carried []provider.Message, reload bool) (control.SessionAPI, normalizedTabRuntime, string, error) {
1991 opts := boot.Options{
1992 Model: model, RequireKey: false,
1993 RuntimeReload: boot.RuntimeReload{ForceFullRebuild: reload},
1994 StatsSource: "desktop",
1995 TaskStore: a.taskStore(),
1996 OnConfigLoadWarnings: a.configLoadWarningsHandler(),
1997 Sink: snap.sink,
1998 WorkspaceRoot: snap.workspaceRoot,
1999 SessionDir: sessionDirForSnapshot(snap),
2000 SessionService: a.desktopSessionService(sessionDirForSnapshot(snap)),
2001 EffortOverride: cloneStringPtr(snap.effort),
2002 EffortModel: snap.model,
2003 SharedHost: a.lookupSharedHost(snap.sharedHostKey), BrowserExecutor: a.browserExecutorForTab(tab),
2004 CleanupPendingReconciler: reconcileDesktopCleanupPending,
2005 SubagentParentLive: a.subagentParentProbeForBuild(tab),
2006 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
2007 PinnedContextLoader: pinnedContextLoader(snap.workspaceRoot),
2008 OnSessionRecovered: a.handleTabSessionRecovered(tab),
2009 OnSessionTransition: a.handleTabSessionTransition(tab),
2010 BeforeInboxDispatch: a.beforeInboxDispatch,
2011 OnSessionTitleChanged: a.onSessionTitleChanged,
2012 }
2013 _, _, exclusiveV3 := exclusiveSessionBinding(oldCtrl)
2014 if oldCtrl != nil && (reload || exclusiveV3) {
2015 old, ok := oldCtrl.(*control.Controller)
2016 if !ok {
2017 return nil, normalizedTabRuntime{}, "", fmt.Errorf("reload runtime: controller does not support model snapshots")
2018 }
2019 if opts.SessionTemp == nil {
2020 opts.SessionTemp = old.SessionTemp()
2021 }
2022 res, err := rebuildTabRuntime(a, tab, old, opts)
2023 if err != nil {
2024 return nil, normalizedTabRuntime{}, "", err
2025 }
2026 ctrl := res.Controller
2027 a.bindControllerDisplayRecorder(ctrl)
2028 // boot.Rebuild migrated history (same session file, fresh system
2029 // prompt spliced), approval mode and grants, plan/goal state, and
2030 // lifecycle. The interactive approval gate and the plan/yolo tab
2031 // mode are desktop wiring Rebuild deliberately leaves out — the
2032 // mode re-apply also restores yolo, which Rebuild does not carry.
2033 ctrl.EnableInteractiveApproval()
2034 applyTabModeToController(ctrl, runtime.tabMode())
2035 // Same path Rebuild pinned internally (identical inputs), recomputed
2036 // for the lease move and the post-swap persistence.
2037 path := ""
2038 if !exclusiveV3 {
2039 path = agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
2040 if err := a.ensureTabSessionLeaseForRebuild(tab, path, setting); err != nil {
2041 ctrl.Close()
2042 return nil, normalizedTabRuntime{}, "", err
2043 }
2044 }
2045 restoredRuntime, err := normalizeRestoredControllerRuntime(ctrl, runtime)
2046 if err != nil {
2047 discardReplacementController(ctrl, oldCtrl)
2048 return nil, normalizedTabRuntime{}, "", err
2049 }
2050 return ctrl, restoredRuntime, path, nil
2051 }
2052 return a.buildLegacySettingReplacement(tab, runtime, opts, oldCtrl, carried, prevPath, setting)
2053 }
2054
2055 // runtimeReloadSettingLabel is the settings-style label used in busy/lease
2056 // error text and notices for an explicit runtime reload.
2057 const runtimeReloadSettingLabel = "runtime reload"
2058
2059 // ReloadRuntime rebuilds the tab's agent runtime in place — tools, skills,
2060 // commands, hooks, providers, and MCP servers are re-discovered from the
2061 // current config — while the session carries over (transcript, approval
2062 // grants, goal/recovery state, shared plugin Host) via boot.Rebuild. Active
2063 // work or a held lease queues exactly one reload on the deferred-rebuild
2064 // loop, which runs it once the tab is idle; a failure keeps the old
2065 // controller fully usable.
2066 func (a *App) ReloadRuntime(tabID string) error {
2067 if a.ctx == nil {
2068 return nil
2069 }
2070 tab := a.tabByID(tabID)
2071 if tab == nil || tab.ID != tabID {
2072 return fmt.Errorf("unknown tab %q", tabID)
2073 }
2074 // Same serialization as rebuildSetting: two build+swap sequences on the
2075 // same tab must not interleave.
2076 a.runtimeRebuildMu.Lock()
2077 err := a.reloadRuntimeTurnLocked(tab)
2078 a.runtimeRebuildMu.Unlock()
2079 if err == nil {
2080 return nil
2081 }
2082 var busy *rebuildBusyError
2083 if errors.As(err, &busy) || errors.Is(err, agent.ErrSessionLeaseHeld) {
2084 // Queue exactly one reload per tab (the pending map coalesces
2085 // duplicates); the loop retries once the work finishes or the lease
2086 // clears.
2087 a.scheduleDeferredRebuild(tab.ID, deferredRuntimeReloadLabel)
2088 a.noticeForTab(tab.ID, "runtime reload queued: will run when the current work finishes")
2089 return nil
2090 }
2091 return err
2092 }
2093
2094 // reloadRuntimeTurnLocked runs the in-place runtime reload for tab; callers
2095 // hold runtimeRebuildMu (the deferred-rebuild retry loop also drives it).
2096 func (a *App) reloadRuntimeTurnLocked(tab *WorkspaceTab) error {
2097 if a.ctx == nil {
2098 return nil
2099 }
2100 tab.turnStartMu.Lock()
2101 defer tab.turnStartMu.Unlock()
2102 return a.rebuildSettingTurnLocked(runtimeReloadSettingLabel, tab, false, true)
2103 }
2104
2105 // SetDefaultModel changes the default for NEW sessions only.
2106 func (a *App) SetDefaultModel(ref string) error {
2107 return a.applyModelConfigChange(func(c *config.Config) error { return setDefaultModelConfig(c, ref) })
2108 }
2109
2110 // SetPlannerModel sets (or, with "", clears) the two-model planner.
2111 func (a *App) SetPlannerModel(ref string) error {
2112 return a.applyModelConfigChange(func(c *config.Config) error { return setPlannerModelConfig(c, ref) })
2113 }
2114
2115 // SetVisionModel sets (or clears) the optional image-understanding fallback.
2116 func (a *App) SetVisionModel(ref string) error {
2117 return a.applyModelConfigChange(func(c *config.Config) error { return setVisionModelConfig(c, ref) })
2118 }
2119
2120 // SetSubagentModel sets (or clears) the default model used by subagent entry points.
2121 func (a *App) SetSubagentModel(ref string) error {
2122 return a.applyModelConfigChange(func(c *config.Config) error { return setSubagentModelConfig(c, ref) })
2123 }
2124
2125 func selectableDesktopModelRef(c *config.Config, ref string) (string, error) {
2126 entry, ok := c.ResolveModel(ref)
2127 if !ok {
2128 return "", fmt.Errorf("unknown model %q", ref)
2129 }
2130 if !modelProviderAccessAllowed(c.Desktop.ProviderAccess, entry.Name) {
2131 return "", fmt.Errorf("model %q is not available because provider %q is not added", ref, entry.Name)
2132 }
2133 if !entry.Configured() {
2134 return "", fmt.Errorf("model %q is not available because provider %q has no key", ref, entry.Name)
2135 }
2136 return entry.Name + "/" + entry.Model, nil
2137 }
2138
2139 func selectableDesktopVisionModelRef(c *config.Config, ref string) (string, error) {
2140 entry, ok := c.ResolveModel(strings.TrimSpace(ref))
2141 if !ok {
2142 return "", fmt.Errorf("unknown vision model %q", ref)
2143 }
2144 if !modelProviderAccessAllowed(c.Desktop.ProviderAccess, entry.Name) {
2145 return "", fmt.Errorf("vision model %q is not available because provider %q is not added", ref, entry.Name)
2146 }
2147 if !entry.Configured() {
2148 return "", fmt.Errorf("vision model %q is not available because provider %q has no key", ref, entry.Name)
2149 }
2150 if !config.EffectiveVision(entry) {
2151 return "", fmt.Errorf("model %q does not support image input", ref)
2152 }
2153 return entry.Name + "/" + entry.Model, nil
2154 }
2155
2156 // SetSubagentEffort sets (or clears) the default effort used by subagent entry points.
2157 func (a *App) SetSubagentEffort(level string) error {
2158 return a.applyModelConfigChange(func(c *config.Config) error { return setSubagentEffortConfig(c, level) })
2159 }
2160
2161 // deleteSubagentOverrideAliases removes every underscore/hyphen alias entry
2162 // for name (boot.SubagentModelKeys — the same key set runtime dispatch
2163 // reads). Deleting only the exact key would leave a legacy alias entry (e.g.
2164 // `security_review` for the security-review skill) silently active.
2165 func deleteSubagentOverrideAliases(overrides map[string]string, name string) {
2166 for _, key := range boot.SubagentModelKeys(name) {
2167 delete(overrides, key)
2168 }
2169 }
2170
2171 // SetSubagentProfileModel sets (or clears) a per-name model override for a
2172 // subagent — the only way to influence a built-in subagent's model in the
2173 // Subagents settings page, since built-ins have no editable frontmatter file
2174 // to carry a `model:` line. Writes into the same cfg.Agent.SubagentModels map
2175 // internal/boot's subagentModelRef already reads at dispatch time. Set and
2176 // clear both sweep the underscore/hyphen alias keys so a legacy alias entry
2177 // can neither shadow the new value nor survive a clear.
2178 func (a *App) SetSubagentProfileModel(name, ref string) error {
2179 return a.applyModelConfigChange(func(c *config.Config) error { return setSubagentProfileModelConfig(c, name, ref) })
2180 }
2181
2182 // SetSubagentProfileEffort sets (or clears) a per-name effort override. See
2183 // SetSubagentProfileModel.
2184 func (a *App) SetSubagentProfileEffort(name, level string) error {
2185 return a.applyModelConfigChange(func(c *config.Config) error { return setSubagentProfileEffortConfig(c, name, level) })
2186 }
2187
2188 func desktopMaxSubagentDepth(depth int) int {
2189 if depth <= 0 {
2190 return agent.DefaultMaxSubagentDepth
2191 }
2192 if depth == 1 {
2193 return 1
2194 }
2195 return agent.DefaultMaxSubagentDepth
2196 }
2197
2198 // SetMaxSubagentDepth controls whether first-layer subagents may delegate once more.
2199 func (a *App) SetMaxSubagentDepth(depth int) error {
2200 return a.applyModelConfigChange(func(c *config.Config) error { return setMaxSubagentDepthConfig(c, depth) })
2201 }
2202
2203 func desktopSubagentConcurrency(n int) int {
2204 total, _ := agent.NormalizeConcurrencyLimits(n, 0)
2205 return total
2206 }
2207
2208 func desktopParallelWriters(writers, total int) int {
2209 _, w := agent.NormalizeConcurrencyLimits(total, writers)
2210 return w
2211 }
2212
2213 // SetMaxSubagentConcurrency sets the session-wide sub-agent concurrency cap (1–32).
2214 func (a *App) SetMaxSubagentConcurrency(n int) error {
2215 return a.applyModelConfigChange(func(c *config.Config) error { return setMaxSubagentConcurrencyConfig(c, n) })
2216 }
2217
2218 // SetMaxParallelWriters sets the concurrent writer cap (1–32, ≤ total concurrency).
2219 func (a *App) SetMaxParallelWriters(n int) error {
2220 return a.applyModelConfigChange(func(c *config.Config) error { return setMaxParallelWritersConfig(c, n) })
2221 }
2222
2223 // SetAutoPlan is retained for older frontend bundles. Automatic plan mode is
2224 // retired, so "off" is an idempotent compatibility call and enabling it is
2225 // rejected without mutating user configuration or live controllers.
2226 func (a *App) SetAutoPlan(mode string) error {
2227 return config.Default().SetAutoPlan(mode)
2228 }
2229
2230 // SetDefaultToolApprovalMode updates the permission preset used only for newly
2231 // created desktop sessions. Existing tabs keep their persisted preset.
2232 func (a *App) SetDefaultToolApprovalMode(mode string) error {
2233 return a.applyConfigOnly(func(c *config.Config) error {
2234 return c.SetDesktopDefaultToolApprovalMode(mode)
2235 })
2236 }
2237
2238 // SetDefaultAutoRecoveryCheckpoint is retained as a no-op bridge surface for
2239 // older generated frontends. Auto Guard is retired.
2240 func (a *App) SetDefaultAutoRecoveryCheckpoint(_ bool) error { return nil }
2241
2242 func officialProviderTemplate(kind, pricingLanguage string) ([]config.ProviderEntry, string, error) {
2243 _ = pricingLanguage // display language no longer selects list-price tables
2244 webSearchEnabled := true
2245 switch strings.ToLower(strings.TrimSpace(kind)) {
2246 case "deepseek", "deepseek-official":
2247 // Freeze the official USD regional table; display currency is independent.
2248 return []config.ProviderEntry{{
2249 Name: "deepseek",
2250 Kind: "openai",
2251 BaseURL: "https://api.deepseek.com",
2252 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
2253 Default: "deepseek-v4-flash",
2254 APIKeyEnv: "DEEPSEEK_API_KEY",
2255 BalanceURL: "https://api.deepseek.com/user/balance",
2256 Thinking: "enabled",
2257 WebSearch: &webSearchEnabled,
2258 ContextWindow: 1_000_000,
2259 BillingCurrency: "USD",
2260 BillingMode: "payg",
2261 Prices: config.DeepSeekV4PricesForCurrency("USD"),
2262 ModelOverrides: map[string]config.ProviderModelOverride{
2263 "deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
2264 "deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
2265 },
2266 }}, "DEEPSEEK_API_KEY", nil
2267 default:
2268 return nil, "", fmt.Errorf("unknown official provider template %q", kind)
2269 }
2270 }
2271
2272 func chatProviderModels(models []string) []string {
2273 out := make([]string, 0, len(models))
2274 seen := map[string]bool{}
2275 for _, model := range models {
2276 model = strings.TrimSpace(model)
2277 if model == "" || seen[model] || !config.IsLikelyChatModel(model) {
2278 continue
2279 }
2280 seen[model] = true
2281 out = append(out, model)
2282 }
2283 return out
2284 }
2285
2286 func providerVisionModels(models, visionModels []string) []string {
2287 enabled := map[string]bool{}
2288 for _, model := range models {
2289 enabled[model] = true
2290 }
2291 out := make([]string, 0, len(visionModels))
2292 for _, model := range chatProviderModels(visionModels) {
2293 if enabled[model] {
2294 out = append(out, model)
2295 }
2296 }
2297 return out
2298 }
2299
2300 func providerDefaultForModels(currentDefault string, models []string) string {
2301 currentDefault = strings.TrimSpace(currentDefault)
2302 if currentDefault != "" {
2303 if slices.Contains(models, currentDefault) {
2304 return currentDefault
2305 }
2306 }
2307 if len(models) > 0 {
2308 return models[0]
2309 }
2310 return ""
2311 }
2312
2313 func saveProviderConfig(c *config.Config, p ProviderView) error {
2314 if c == nil {
2315 return fmt.Errorf("config is nil")
2316 }
2317 e := config.ProviderEntry{Name: p.Name}
2318 existing := false
2319 for i := range c.Providers {
2320 if c.Providers[i].Name == p.Name {
2321 e = c.Providers[i]
2322 existing = true
2323 break
2324 }
2325 }
2326 original := e
2327 e.Name = p.Name
2328 if p.DisplayName != nil {
2329 e.DisplayName = strings.TrimSpace(*p.DisplayName)
2330 }
2331 e.Kind = p.Kind
2332 e.BaseURL = p.BaseURL
2333 e.ChatURL = strings.TrimSpace(p.ChatURL)
2334 e.RequestURL = strings.TrimSpace(p.RequestURL)
2335 if strings.EqualFold(strings.TrimSpace(e.Kind), "openai") && e.RequestURL != "" {
2336 e.ChatURL = e.RequestURL
2337 }
2338 e.ModelsURL = strings.TrimSpace(p.ModelsURL)
2339 e.APIKeyEnv = p.APIKeyEnv
2340 e.Headers = p.Headers
2341 e.ExtraBody = p.ExtraBody
2342 e.AuthHeader = p.AuthHeader
2343 config.RepairProviderEndpointContract(&e)
2344 e.NoProxy = p.NoProxy
2345 e.BalanceURL = strings.TrimSpace(p.BalanceURL)
2346 e.ContextWindow = p.ContextWindow
2347 e.ReasoningProtocol = p.ReasoningProtocol
2348 e.Thinking = providerThinkingForSettings(p.Thinking)
2349 // Preserve advanced search overrides only for verified endpoints, never for a new URL.
2350 if config.IsOfficialDeepSeekSearchEndpoint(&e) {
2351 enabled := p.WebSearch
2352 e.WebSearch = &enabled
2353 } else if !config.SupportsServerWebSearch(&e) || !existing || config.IsOfficialDeepSeekSearchEndpoint(&original) {
2354 e.WebSearch = nil
2355 }
2356 e.SupportedEfforts = p.SupportedEfforts
2357 e.DefaultEffort = p.DefaultEffort
2358 e.Model = ""
2359 e.Models = nil
2360 e.Default = ""
2361 e.VisionModels = nil
2362 models := chatProviderModels(p.Models)
2363 if len(models) > 0 {
2364 e.Model = models[0] // also satisfies validateProvider's model requirement
2365 e.Models = models
2366 e.VisionModels = providerVisionModels(models, original.VisionModels)
2367 e.ModelOverrides = providerModelOverridesForSave(p.ModelOverrides, models)
2368 if p.VisionModelsSet || len(p.VisionModels) > 0 {
2369 e.Vision = false
2370 e.VisionModels = providerVisionModels(models, p.VisionModels)
2371 }
2372 if len(models) > 1 {
2373 e.Default = providerDefaultForModels(p.Default, models)
2374 }
2375 } else {
2376 e.Vision = false
2377 e.VisionModels = nil
2378 e.ModelOverrides = nil
2379 }
2380 if err := config.ValidateProviderEndpoint(&e); err != nil {
2381 return err
2382 }
2383 if err := c.UpsertProvider(e); err != nil {
2384 return err
2385 }
2386 addProviderAccess(c, p.Name)
2387 return nil
2388 }
2389
2390 // RenameProviderConnections updates display metadata only; route identities and
2391 // other settings are read from the latest configuration under the edit lock.
2392 func (a *App) RenameProviderConnections(names []string, displayName string) error {
2393 return a.applyModelConfigChange(func(c *config.Config) error { return renameProviderConnections(c, names, displayName) })
2394 }
2395
2396 func renameProviderConnections(c *config.Config, names []string, displayName string) error {
2397 for _, name := range names {
2398 if _, ok := c.Provider(name); !ok {
2399 return fmt.Errorf("provider %q not found", name)
2400 }
2401 }
2402 for _, name := range names {
2403 p, _ := c.Provider(name)
2404 p.DisplayName = strings.TrimSpace(displayName)
2405 }
2406 return nil
2407 }
2408
2409 // SaveProvider adds or updates a provider. Enabled models are persisted through
2410 // `models` even when only one model is selected, while `model` remains populated
2411 // in-memory for validation/back-compat. The shared key/endpoint live on the entry.
2412 func (a *App) SaveProvider(p ProviderView) error {
2413 return a.applyModelConfigChange(func(c *config.Config) error {
2414 return saveProviderConfig(c, p)
2415 })
2416 }
2417
2418 // SetProviderWebSearch updates every provider represented by one Settings
2419 // access card in a single config transaction. Legacy DeepSeek aliases can
2420 // remain separate when their custom transport fields differ, so changing only
2421 // the first profile would leave the grouped control in a contradictory state.
2422 func (a *App) SetProviderWebSearch(names []string, enabled bool) error {
2423 return a.applyModelConfigChange(func(c *config.Config) error {
2424 return setProviderWebSearchConfig(c, names, enabled)
2425 })
2426 }
2427
2428 func setProviderWebSearchConfig(c *config.Config, names []string, enabled bool) error {
2429 seen := make(map[string]bool, len(names))
2430 providers := make([]*config.ProviderEntry, 0, len(names))
2431 for _, rawName := range names {
2432 name := strings.TrimSpace(rawName)
2433 if name == "" || seen[name] {
2434 continue
2435 }
2436 seen[name] = true
2437 entry, ok := c.Provider(name)
2438 if !ok {
2439 return fmt.Errorf("provider %q not found", name)
2440 }
2441 if !config.IsOfficialDeepSeekSearchEndpoint(entry) {
2442 return fmt.Errorf("provider %q does not support configurable server-side web search", name)
2443 }
2444 providers = append(providers, entry)
2445 }
2446 if len(providers) == 0 {
2447 return fmt.Errorf("provider list is empty")
2448 }
2449 for _, entry := range providers {
2450 value := enabled
2451 entry.WebSearch = &value
2452 }
2453 return nil
2454 }
2455
2456 func providerModelOverridesForCatalog(overrides map[string]config.ProviderModelOverride, models []string) map[string]config.ProviderModelOverride {
2457 if len(overrides) == 0 {
2458 return nil
2459 }
2460 allowed := make(map[string]bool, len(models))
2461 for _, model := range models {
2462 allowed[model] = true
2463 }
2464 filtered := make(map[string]config.ProviderModelOverride, len(overrides))
2465 for model, override := range overrides {
2466 if allowed[model] {
2467 filtered[model] = override
2468 }
2469 }
2470 if len(filtered) == 0 {
2471 return nil
2472 }
2473 return filtered
2474 }
2475
2476 func applyProviderModelCatalogUpdate(c *config.Config, update ProviderModelCatalogUpdate, credentialsRevision string) (bool, error) {
2477 if c == nil {
2478 return false, fmt.Errorf("config is nil")
2479 }
2480 current, ok := c.Provider(strings.TrimSpace(update.Name))
2481 if !ok || strings.TrimSpace(update.ExpectedFingerprint) == "" ||
2482 providerModelCatalogFingerprintForCredentials(*current, credentialsRevision) != strings.TrimSpace(update.ExpectedFingerprint) {
2483 return false, nil
2484 }
2485 models := chatProviderModels(update.Models)
2486 if len(models) == 0 {
2487 return false, fmt.Errorf("provider %q model catalog is empty", update.Name)
2488 }
2489
2490 next := *current
2491 visionConfigured := next.Vision || next.VisionModels != nil
2492 next.Model = models[0] // keep validation/back-compat populated
2493 next.Models = models
2494 next.Default = ""
2495 if len(models) > 1 {
2496 next.Default = providerDefaultForModels(update.Default, models)
2497 }
2498 next.Vision = false
2499 if visionConfigured {
2500 next.VisionModels = providerVisionModels(models, update.VisionModels)
2501 } else {
2502 next.VisionModels = nil
2503 }
2504 next.ModelOverrides = providerModelOverridesForCatalog(next.ModelOverrides, models)
2505 if config.ProviderEntriesConfigEqual(*current, next) {
2506 return false, nil
2507 }
2508 if err := c.UpsertProvider(next); err != nil {
2509 return false, err
2510 }
2511 return true, nil
2512 }
2513
2514 // SaveProviderModelCatalogs applies only model-catalog fields. Each update is
2515 // compared against the provider snapshot that launched discovery while the
2516 // config edit lock is held, so an older async completion cannot overwrite newer
2517 // provider edits. Stale updates are skipped rather than treated as failures.
2518 func (a *App) SaveProviderModelCatalogs(updates []ProviderModelCatalogUpdate) ([]string, error) {
2519 if len(updates) == 0 {
2520 return []string{}, nil
2521 }
2522 applied := make([]string, 0, len(updates))
2523 if err := func() error {
2524 unlock := config.LockUserConfigEdits()
2525 defer unlock()
2526 cfg, path, err := a.loadDesktopUserConfigForEdit()
2527 if err != nil {
2528 return err
2529 }
2530 observedCredentialsRevision := providerCredentialsRevision()
2531 if a.providerCatalogBeforeCredentialLockHook != nil {
2532 a.providerCatalogBeforeCredentialLockHook(observedCredentialsRevision)
2533 }
2534 unlockCredentials, err := config.LockUserCredentialEdits()
2535 if err != nil {
2536 return err
2537 }
2538 defer unlockCredentials()
2539 // Re-read while holding the same lock as every Reasonix credential
2540 // writer, then keep that lock through the config commit. A rotation that
2541 // won the race therefore invalidates the request fingerprint.
2542 credentialsRevision := providerCredentialsRevision()
2543 baseline := cfg.ModelSettingsBaseline()
2544 for _, update := range updates {
2545 changed, err := applyProviderModelCatalogUpdate(cfg, update, credentialsRevision)
2546 if err != nil {
2547 return err
2548 }
2549 if changed {
2550 applied = append(applied, strings.TrimSpace(update.Name))
2551 }
2552 }
2553 if len(applied) == 0 {
2554 return nil
2555 }
2556 return cfg.SaveModelSettingsTo(path, baseline)
2557 }(); err != nil {
2558 return []string{}, err
2559 }
2560 if len(applied) == 0 {
2561 return applied, nil
2562 }
2563 a.modelSettingsSaved("provider model catalogs")
2564 return applied, nil
2565 }
2566
2567 // SaveProviderWithKey saves a custom provider and its credential as one settings
2568 // transaction, then rebuilds once after both are visible to the runtime.
2569 func (a *App) SaveProviderWithKey(p ProviderView, key string) (string, error) {
2570 return a.applyModelConfigChangeWithWarning("provider", func(c *config.Config) error {
2571 if err := saveProviderConfig(c, p); err != nil {
2572 return err
2573 }
2574 env, err := c.StageModelCredentialLocked(key)
2575 if err != nil {
2576 return err
2577 }
2578 for i := range c.Providers {
2579 if c.Providers[i].Name == p.Name {
2580 c.Providers[i].APIKeyEnv = env
2581 }
2582 }
2583 return nil
2584 })
2585 }
2586
2587 // UpgradeDeepSeekProviderAccess applies the explicit Settings action for an
2588 // official legacy OpenAI entry. The config package performs a narrow raw-TOML
2589 // edit so unrelated and future fields are not lost to a full config render.
2590 func (a *App) UpgradeDeepSeekProviderAccess(name string) (string, error) {
2591 changed, err := config.UpgradeDeepSeekProviderProtocolUserConfig(name)
2592 if err != nil {
2593 return "", err
2594 }
2595 if !changed {
2596 return "", fmt.Errorf("DeepSeek provider %q is not eligible for the recommended protocol upgrade", name)
2597 }
2598 a.modelSettingsSaved("DeepSeek provider protocol")
2599 return "", nil
2600 }
2601
2602 // AddProviderPresetAccess installs one editable custom-provider preset. Unlike
2603 // official built-ins, these entries are saved as normal providers so users can
2604 // tweak endpoints, model lists, and capability overrides after the one-click
2605 // setup path.
2606 func (a *App) AddProviderPresetAccess(id, key string) (string, error) {
2607 return a.applyModelConfigChangeWithWarning("provider access", func(c *config.Config) error { return addProviderPresetConfig(c, id, key) })
2608 }
2609
2610 func addProviderPresetConfig(c *config.Config, id, key string) error {
2611 preset, ok := config.CuratedProviderPreset(id)
2612 if !ok {
2613 return fmt.Errorf("unknown provider preset %q", id)
2614 }
2615 if len(preset.Entries) == 0 {
2616 return fmt.Errorf("provider preset %q has no provider entries", id)
2617 }
2618 keyEnv := strings.TrimSpace(preset.KeyEnv)
2619 if keyEnv == "" {
2620 for _, e := range preset.Entries {
2621 if keyEnv = strings.TrimSpace(e.APIKeyEnv); keyEnv != "" {
2622 break
2623 }
2624 }
2625 }
2626 missing, _, conflicts := providerPresetInstallPlan(c, preset)
2627 if len(conflicts) > 0 {
2628 return providerPresetAlreadyAddedError(preset.ID, conflicts)
2629 }
2630 if len(missing) == 0 {
2631 return nil
2632 }
2633 names := make([]string, 0, len(missing))
2634 for _, e := range missing {
2635 if strings.TrimSpace(key) != "" {
2636 e.APIKeyEnv = keyEnv
2637 }
2638 if e.DisplayName == "" {
2639 e.DisplayName = preset.Label
2640 }
2641 if err := c.UpsertProvider(e); err != nil {
2642 return err
2643 }
2644 names = append(names, e.Name)
2645 }
2646 addProviderAccess(c, names...)
2647 if preset.ID == "opencode-go-recommended" && providerDefaultNeedsReplacement(c) {
2648 if err := c.SetDefaultModel("opencode-go/glm-5.3"); err != nil {
2649 return err
2650 }
2651 }
2652 if strings.TrimSpace(key) != "" {
2653 env, err := c.StageModelCredentialLocked(key)
2654 if err != nil {
2655 return err
2656 }
2657 for _, route := range preset.Entries {
2658 if entry, ok := c.Provider(route.Name); ok {
2659 entry.APIKeyEnv = env
2660 }
2661 }
2662 }
2663 return nil
2664 }
2665
2666 func providerDefaultNeedsReplacement(c *config.Config) bool {
2667 if c == nil || strings.TrimSpace(c.DefaultModel) == "" {
2668 return true
2669 }
2670 entry, ok := c.ResolveModel(c.DefaultModel)
2671 return !ok || !entry.Configured()
2672 }
2673
2674 // ResetProviderPresetAccess intentionally overwrites same-name provider entries
2675 // with the curated preset template. It only mutates config; provider secrets stay
2676 // in Reasonix home .env under whichever api_key_env the resulting preset uses.
2677 func (a *App) ResetProviderPresetAccess(id string) error {
2678 return a.applyModelConfigChange(func(c *config.Config) error { return resetProviderPresetConfig(c, id) })
2679 }
2680
2681 func resetProviderPresetConfig(c *config.Config, id string) error {
2682 preset, ok := config.CuratedProviderPreset(id)
2683 if !ok {
2684 return fmt.Errorf("unknown provider preset %q", id)
2685 }
2686 if len(preset.Entries) == 0 {
2687 return fmt.Errorf("provider preset %q has no provider entries", id)
2688 }
2689 if existing := existingProviderNames(c, preset.Entries); len(existing) == 0 {
2690 return providerPresetNoExistingProviderError(preset.ID)
2691 }
2692 names := make([]string, 0, len(preset.Entries))
2693 for _, e := range preset.Entries {
2694 if existing, ok := c.Provider(e.Name); ok {
2695 e.APIKeyEnv = existing.APIKeyEnv
2696 }
2697 if err := c.UpsertProvider(e); err != nil {
2698 return err
2699 }
2700 names = append(names, e.Name)
2701 }
2702 addProviderAccess(c, names...)
2703 return nil
2704 }
2705
2706 func existingProviderNames(c *config.Config, entries []config.ProviderEntry) []string {
2707 if c == nil || len(entries) == 0 {
2708 return nil
2709 }
2710 names := make([]string, 0, len(entries))
2711 for _, entry := range entries {
2712 name := strings.TrimSpace(entry.Name)
2713 if name == "" {
2714 continue
2715 }
2716 if _, ok := c.Provider(name); ok {
2717 names = append(names, name)
2718 }
2719 }
2720 return names
2721 }
2722
2723 // providerPresetInstallPlan makes preset installation idempotent while still
2724 // refusing to overwrite a same-name provider that belongs to another route.
2725 // Existing entries that match the preset's provider identity are preserved;
2726 // modified entries are reported separately, and only missing entries are
2727 // returned for installation.
2728 func providerPresetInstallPlan(c *config.Config, preset config.ProviderPreset) (missing, modified []config.ProviderEntry, conflicts []string) {
2729 if c == nil {
2730 return append([]config.ProviderEntry(nil), preset.Entries...), nil, nil
2731 }
2732 for _, entry := range preset.Entries {
2733 name := strings.TrimSpace(entry.Name)
2734 if name == "" {
2735 continue
2736 }
2737 existing, ok := c.Provider(name)
2738 if !ok {
2739 missing = append(missing, entry)
2740 continue
2741 }
2742 if providerEntryCoreMatches(*existing, entry) {
2743 continue
2744 }
2745 if providerEntryBelongsToPreset(*existing, preset, entry) {
2746 modified = append(modified, entry)
2747 continue
2748 }
2749 conflicts = append(conflicts, name)
2750 }
2751 return missing, modified, conflicts
2752 }
2753
2754 func providerPresetAlreadyAddedError(id string, names []string) error {
2755 return fmt.Errorf("provider preset %q cannot be added because provider name(s) already exist: %s; edit, rename, or remove the existing provider before adding it again", id, strings.Join(names, ", "))
2756 }
2757
2758 func providerPresetNoExistingProviderError(id string) error {
2759 return fmt.Errorf("provider preset %q cannot be reset because no same-name provider exists; add the preset instead", id)
2760 }
2761
2762 // FetchProviderModels probes the provider's OpenAI-compatible model-list
2763 // endpoint and returns the available model IDs. This is a settings-only helper:
2764 // it never touches chat request serialization or provider-visible prompt data.
2765 // The probe rides the configured network proxy so a broken proxy path fails
2766 // here, at setup time, instead of succeeding and stalling chat later (#9560).
2767 func (a *App) FetchProviderModelCatalog(p ProviderView) ([]ProviderModelCapabilityView, error) {
2768 return a.FetchProviderModelCatalogDraft(p, "")
2769 }
2770
2771 // FetchProviderModels is the legacy ID-only wrapper retained for older
2772 // frontends and callers.
2773 func (a *App) FetchProviderModels(p ProviderView) ([]string, error) {
2774 catalog, err := a.FetchProviderModelCatalog(p)
2775 if err != nil {
2776 return []string{}, err
2777 }
2778 models := make([]string, 0, len(catalog))
2779 for _, model := range catalog {
2780 models = append(models, model.Model)
2781 }
2782 return nonNil(chatProviderModels(models)), nil
2783 }
2784
2785 // networkProxySpecForRoot resolves the effective proxy policy chat requests use
2786 // for this workspace. The load includes project reasonix.toml and project .env
2787 // expansion but never pins provider credentials into the process environment.
2788 // A missing or unreadable config falls back to the default policy rather than
2789 // blocking model discovery.
2790 func (a *App) networkProxySpecForRoot(root string) netclient.ProxySpec {
2791 cfg, err := config.LoadForRootWithoutCredentialsReadOnly(root)
2792 if err != nil || cfg == nil {
2793 return netclient.ProxySpec{}
2794 }
2795 return cfg.NetworkProxySpec()
2796 }
2797
2798 // withProbeDirectHost mirrors the runtime's per-provider no_proxy bypass for the
2799 // unsaved editor state: when the edited provider is marked no_proxy, its
2800 // endpoint must also be probed directly. Custom proxy mode wins over provider
2801 // no_proxy, matching NetworkProxySpec's behavior.
2802 func withProbeDirectHost(spec netclient.ProxySpec, baseURL string, noProxy bool) netclient.ProxySpec {
2803 if !noProxy || netclient.NormalizeMode(spec.Mode) == netclient.ModeCustom {
2804 return spec
2805 }
2806 u, err := url.Parse(strings.TrimSpace(baseURL))
2807 if err != nil {
2808 return spec
2809 }
2810 host := u.Hostname()
2811 if host == "" || slices.Contains(spec.DirectHosts, host) {
2812 return spec
2813 }
2814 spec.DirectHosts = append([]string{host}, spec.DirectHosts...)
2815 return spec
2816 }
2817
2818 // FetchAllProviderModels fetches model lists for all providers in a single
2819 // batch. Models are fetched concurrently (up to 4 parallel requests) and
2820 // returned as a map keyed by provider name. Errors for individual providers
2821 // are recorded as nil entries; callers should handle missing keys.
2822 func (a *App) FetchAllProviderModels(providers []ProviderView) map[string][]string {
2823 results := make(map[string][]string, len(providers))
2824 var mu sync.Mutex
2825 g, ctx := errgroup.WithContext(a.reqCtx())
2826 g.SetLimit(4)
2827 root := a.activeWorkspaceRoot()
2828 proxy := a.networkProxySpecForRoot(root)
2829 for i := range providers {
2830 p := providers[i]
2831 g.Go(func() error {
2832 e := config.ProviderEntry{
2833 Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL, RequestURL: p.RequestURL,
2834 ModelsURL: strings.TrimSpace(p.ModelsURL),
2835 APIKeyEnv: p.APIKeyEnv,
2836 Headers: p.Headers,
2837 AuthHeader: p.AuthHeader, NoProxy: p.NoProxy,
2838 }
2839 e.ResolveAPIKeyForRoot(root)
2840 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
2841 defer cancel()
2842 models, err := e.FetchModelsWithProxy(ctx, withProbeDirectHost(proxy, e.BaseURL, e.NoProxy))
2843 if err != nil {
2844 // Omit failed providers so the frontend can retry them through
2845 // the cached single-provider path without emitting JSON null.
2846 return nil
2847 }
2848 mu.Lock()
2849 defer mu.Unlock()
2850 results[p.Name] = nonNil(chatProviderModels(models))
2851 return nil
2852 })
2853 }
2854 _ = g.Wait()
2855 return results
2856 }
2857
2858 // FetchAllProviderModelCatalogs is the metadata-preserving batch companion to
2859 // FetchAllProviderModels. Individual provider failures are omitted so callers
2860 // can retry them through the single-provider path.
2861 func (a *App) FetchAllProviderModelCatalogs(providers []ProviderView) map[string][]ProviderModelCapabilityView {
2862 results := make(map[string][]ProviderModelCapabilityView, len(providers))
2863 var mu sync.Mutex
2864 g, ctx := errgroup.WithContext(a.reqCtx())
2865 sem := make(chan struct{}, 4)
2866 for _, p := range providers {
2867 g.Go(func() error {
2868 select {
2869 case sem <- struct{}{}:
2870 case <-ctx.Done():
2871 return ctx.Err()
2872 }
2873 defer func() { <-sem }()
2874 catalog, err := a.FetchProviderModelCatalog(p)
2875 if err != nil {
2876 return nil
2877 }
2878 mu.Lock()
2879 if catalog == nil {
2880 catalog = []ProviderModelCapabilityView{}
2881 }
2882 results[p.Name] = catalog
2883 mu.Unlock()
2884 return nil
2885 })
2886 }
2887 _ = g.Wait()
2888 return results
2889 }
2890
2891 // SetProviderKey writes a secret to Reasonix's global .env under the given
2892 // env-var name (the one a provider's api_key_env points at) and rebuilds so it
2893 // resolves immediately.
2894 func (a *App) SetProviderKey(apiKeyEnv, value string) (string, error) {
2895 apiKeyEnv = strings.TrimSpace(apiKeyEnv)
2896 if apiKeyEnv == "" {
2897 return "", fmt.Errorf("this provider has no api_key_env set")
2898 }
2899 return a.applyModelConfigChangeWithWarning("provider key", func(c *config.Config) error {
2900 names := []string{}
2901 for _, p := range c.Providers {
2902 if p.APIKeyEnv == apiKeyEnv {
2903 names = append(names, p.Name)
2904 }
2905 }
2906 if len(names) == 0 {
2907 return fmt.Errorf("no connection uses this credential; edit the connection instead")
2908 }
2909 env, err := c.StageModelCredentialLocked(value)
2910 if err != nil {
2911 return err
2912 }
2913 for i := range c.Providers {
2914 if c.Providers[i].APIKeyEnv == apiKeyEnv {
2915 c.Providers[i].APIKeyEnv = env
2916 addProviderAccess(c, c.Providers[i].Name)
2917 }
2918 }
2919 return nil
2920 })
2921 }
2922
2923 // SaveProviderKey writes a provider secret without rebuilding the chat runtime.
2924 // It is used by settings probes that need credentials only for a model-list
2925 // request; explicit "save key" actions still call SetProviderKey.
2926 func (a *App) SaveProviderKey(apiKeyEnv, value string) (string, error) {
2927 if strings.TrimSpace(apiKeyEnv) == "" {
2928 return "", fmt.Errorf("this provider has no api_key_env set")
2929 }
2930 return a.SetProviderKey(apiKeyEnv, value)
2931 }
2932
2933 // ClearProviderKey removes a provider secret from Reasonix's global .env
2934 // and rebuilds so the provider immediately becomes unauthenticated.
2935 func (a *App) ClearProviderKey(apiKeyEnv string) error {
2936 _, err := a.SetProviderKey(apiKeyEnv, "")
2937 return err
2938 }
2939
2940 // SetPermissionMode sets the writer-fallback mode (ask|allow|deny).
2941 func (a *App) SetPermissionMode(mode string) error {
2942 return a.applyConfigChange(func(c *config.Config) error { return c.SetPermissionMode(mode) })
2943 }
2944
2945 // AddPermissionRule appends a rule to the allow/ask/deny list.
2946 func (a *App) AddPermissionRule(list, rule string) error {
2947 return a.applyConfigChange(func(c *config.Config) error { return c.AddPermissionRule(list, rule) })
2948 }
2949
2950 // RemovePermissionRule drops a rule from the allow/ask/deny list.
2951 func (a *App) RemovePermissionRule(list, rule string) error {
2952 return a.applyConfigChange(func(c *config.Config) error {
2953 _, err := c.RemovePermissionRule(list, rule)
2954 return err
2955 })
2956 }
2957
2958 // ReloadSettings rebuilds the active controller from the current config without
2959 // changing any config file. It lets manual config.toml edits take effect.
2960 func (a *App) ReloadSettings() error {
2961 if err := a.ensureActiveTabRebuildAllowed("settings"); err != nil {
2962 return err
2963 }
2964 // A manual Git Bash/Bash repair changes the host filesystem without a
2965 // config write. The explicit reload action is the user's request to re-check
2966 // that environment now rather than wait for the discovery TTL.
2967 sandbox.InvalidateShellInventory()
2968 if err := a.rebuild(); err != nil {
2969 // The on-disk config already diverged from the runtime; retry the
2970 // refresh once the other window releases the session lease.
2971 if _, ok := a.deferredRebuildWarning("settings", err); ok {
2972 return nil
2973 }
2974 return err
2975 }
2976 return nil
2977 }
2978
2979 // SetSandbox updates the bash sandbox mode, network egress, and write roots.
2980 func (a *App) SetSandbox(bash string, network bool, workspaceRoot string, allowWrite []string, shell string) error {
2981 return a.applyConfigChange(func(c *config.Config) error {
2982 c.Sandbox.Bash = bash
2983 c.Sandbox.Network = network
2984 c.Sandbox.WorkspaceRoot = strings.TrimSpace(workspaceRoot)
2985 c.Sandbox.AllowWrite = trimList(allowWrite)
2986 c.Tools.Shell.Prefer = strings.TrimSpace(shell)
2987 return nil
2988 })
2989 }
2990
2991 // SetNetwork updates ordinary outbound proxy settings.
2992 func (a *App) SetNetwork(n NetworkView) error {
2993 return a.applyConfigChange(func(c *config.Config) error {
2994 return c.SetNetwork(config.NetworkConfig{
2995 ProxyMode: n.ProxyMode,
2996 ProxyURL: n.ProxyURL,
2997 NoProxy: n.NoProxy,
2998 Proxy: config.NetworkProxyConfig{
2999 Type: n.Proxy.Type,
3000 Server: n.Proxy.Server,
3001 Port: n.Proxy.Port,
3002 Username: n.Proxy.Username,
3003 Password: n.Proxy.Password,
3004 },
3005 })
3006 })
3007 }
3008
3009 func (a *App) SetBotSettings(b BotSettingsView) error {
3010 err := a.applyConfigOnly(func(c *config.Config) error {
3011 c.Bot.Enabled = b.Enabled
3012 c.Bot.Model = strings.TrimSpace(b.Model)
3013 c.Bot.ToolApprovalMode = normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode)
3014 c.Bot.MaxSteps = b.MaxSteps
3015 c.Bot.DebounceMs = b.DebounceMs
3016 c.Bot.QueueMode = strings.TrimSpace(b.QueueMode)
3017 c.Bot.QueueCap = b.QueueCap
3018 c.Bot.QueueDrop = strings.TrimSpace(b.QueueDrop)
3019 c.Bot.IgnoreSelfMessages = b.IgnoreSelfMessages
3020 c.Bot.SelfUserIDs = config.BotSelfUserIDs{
3021 QQ: trimList(b.SelfUserIDs.QQ),
3022 Feishu: trimList(b.SelfUserIDs.Feishu),
3023 Weixin: trimList(b.SelfUserIDs.Weixin),
3024 Dingtalk: trimList(b.SelfUserIDs.Dingtalk),
3025 }
3026 c.Bot.Control = config.BotControlConfig{
3027 Enabled: b.Control.Enabled,
3028 Addr: strings.TrimSpace(b.Control.Addr),
3029 TokenEnv: strings.TrimSpace(b.Control.TokenEnv),
3030 }
3031 c.Bot.Pairing = config.BotPairingConfig{
3032 Enabled: b.Pairing.Enabled,
3033 RequestTTLMinutes: b.Pairing.RequestTTLMinutes,
3034 MaxPendingPerPlatform: b.Pairing.MaxPendingPerPlatform,
3035 }
3036 c.Bot.Routes = botRouteConfigs(b.Routes)
3037 c.Bot.Allowlist = config.BotAllowlist{
3038 Enabled: b.Allowlist.Enabled,
3039 AllowAll: b.Allowlist.AllowAll,
3040 QQUsers: trimList(b.Allowlist.QQUsers),
3041 FeishuUsers: trimList(b.Allowlist.FeishuUsers),
3042 WeixinUsers: trimList(b.Allowlist.WeixinUsers),
3043 QQApprovers: trimList(b.Allowlist.QQApprovers),
3044 FeishuApprovers: trimList(b.Allowlist.FeishuApprovers),
3045 WeixinApprovers: trimList(b.Allowlist.WeixinApprovers),
3046 QQAdmins: trimList(b.Allowlist.QQAdmins),
3047 FeishuAdmins: trimList(b.Allowlist.FeishuAdmins),
3048 WeixinAdmins: trimList(b.Allowlist.WeixinAdmins),
3049 QQGroups: trimList(b.Allowlist.QQGroups),
3050 FeishuGroups: trimList(b.Allowlist.FeishuGroups),
3051 WeixinGroups: trimList(b.Allowlist.WeixinGroups),
3052 DingtalkUsers: trimList(b.Allowlist.DingtalkUsers),
3053 DingtalkApprovers: trimList(b.Allowlist.DingtalkApprovers),
3054 DingtalkAdmins: trimList(b.Allowlist.DingtalkAdmins),
3055 DingtalkGroups: trimList(b.Allowlist.DingtalkGroups),
3056 }
3057 c.Bot.QQ = config.QQBotConfig{
3058 Enabled: b.QQ.Enabled,
3059 AppID: strings.TrimSpace(b.QQ.AppID),
3060 AppSecretEnv: strings.TrimSpace(b.QQ.AppSecretEnv),
3061 Sandbox: b.QQ.Sandbox,
3062 Model: strings.TrimSpace(b.QQ.Model),
3063 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.QQ.ToolApprovalMode),
3064 WorkspaceRoot: strings.TrimSpace(b.QQ.WorkspaceRoot),
3065 Access: botAccessConfigFromView(b.QQ.Access),
3066 }
3067 c.Bot.Feishu = config.FeishuBotConfig{
3068 Enabled: b.Feishu.Enabled,
3069 Domain: botDomainOrDefault(b.Feishu.Domain),
3070 AppID: strings.TrimSpace(b.Feishu.AppID),
3071 AppSecretEnv: strings.TrimSpace(b.Feishu.AppSecretEnv),
3072 VerificationToken: strings.TrimSpace(b.Feishu.VerificationToken),
3073 Mode: strings.TrimSpace(b.Feishu.Mode),
3074 WebhookPort: b.Feishu.WebhookPort,
3075 RequireMention: b.Feishu.RequireMention,
3076 OutboundMediaRoots: append([]string(nil), c.Bot.Feishu.OutboundMediaRoots...),
3077 }
3078 c.Bot.Weixin = config.WeixinBotConfig{
3079 Enabled: b.Weixin.Enabled,
3080 AccountID: strings.TrimSpace(b.Weixin.AccountID),
3081 TokenEnv: strings.TrimSpace(b.Weixin.TokenEnv),
3082 APIBase: strings.TrimRight(strings.TrimSpace(b.Weixin.APIBase), "/"),
3083 }
3084 c.Bot.Dingtalk = dingtalkConfigFromView(b.Dingtalk, c.Bot.Dingtalk)
3085 c.Bot.Connections = botConnectionConfigs(b.Connections)
3086 return nil
3087 })
3088 if err == nil {
3089 a.refreshBotRuntimeAsync()
3090 }
3091 return err
3092 }
3093
3094 // SetBotConnectionToolApprovalMode updates a single connection's tool approval
3095 // mode without restarting the bot gateway. Only the connection's mode field is
3096 // persisted; existing sessions on the running gateway are updated in-place.
3097 func (a *App) SetBotConnectionToolApprovalMode(connID, mode string) error {
3098 connID = strings.TrimSpace(connID)
3099 mode = normalizeBotConnectionToolApprovalMode(mode)
3100 runtimeConnID := connID
3101 err := a.applyConfigOnly(func(c *config.Config) error {
3102 for i := range c.Bot.Connections {
3103 candidateRuntimeID := botruntime.ConnectionRuntimeID(c.Bot.Connections[i])
3104 if candidateRuntimeID == "" {
3105 candidateRuntimeID = strings.TrimSpace(c.Bot.Connections[i].ID)
3106 }
3107 if c.Bot.Connections[i].ID == connID || candidateRuntimeID == connID {
3108 c.Bot.Connections[i].ToolApprovalMode = mode
3109 c.Bot.Connections[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339)
3110 runtimeConnID = candidateRuntimeID
3111 return nil
3112 }
3113 }
3114 return fmt.Errorf("connection %q not found", connID)
3115 })
3116 if err != nil {
3117 return err
3118 }
3119 if a.botRuntime != nil {
3120 a.botRuntime.updateConnectionToolApprovalMode(runtimeConnID, mode)
3121 }
3122 return nil
3123 }
3124
3125 // SetBotDingtalkToolApprovalMode 更新 legacy [bot.dingtalk] 的工具审批模式,
3126 // 不重启 bot runtime:写入配置并热更新运行中 gateway 的
3127 // ConnectionChannels["dingtalk"](由 desktopBotChannelsWithLegacyDingtalk 注入),
3128 // 已建会话同步生效。用于设置面板的权限选择(避免全量 SetBotSettings 的重启跳变)。
3129 func (a *App) SetBotDingtalkToolApprovalMode(mode string) error {
3130 mode = normalizeBotConnectionToolApprovalMode(mode)
3131 err := a.applyConfigOnly(func(c *config.Config) error {
3132 c.Bot.Dingtalk.ToolApprovalMode = mode
3133 return nil
3134 })
3135 if err != nil {
3136 return err
3137 }
3138 if a.botRuntime != nil {
3139 a.botRuntime.updateConnectionToolApprovalMode(string(bot.PlatformDingtalk), mode)
3140 }
3141 return nil
3142 }
3143
3144 func (a *App) SetBotSecret(envName, value string) error {
3145 envName = strings.TrimSpace(envName)
3146 if envName == "" {
3147 return fmt.Errorf("bot secret env name is empty")
3148 }
3149 if err := upsertDotEnv(envName, value); err != nil {
3150 return err
3151 }
3152 a.refreshBotRuntimeAsync()
3153 return nil
3154 }
3155
3156 func (a *App) ClearBotSecret(envName string) error {
3157 envName = strings.TrimSpace(envName)
3158 if envName == "" {
3159 return fmt.Errorf("bot secret env name is empty")
3160 }
3161 if err := removeDotEnv(envName); err != nil {
3162 return err
3163 }
3164 a.refreshBotRuntimeAsync()
3165 return nil
3166 }
3167
3168 // SetAgentParams updates sampling temperature and the base system prompt. The
3169 // step arguments remain in the desktop contract for older frontends, but are
3170 // retired and deliberately normalized to automatic execution.
3171 func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error {
3172 return a.applyConfigChange(func(c *config.Config) error {
3173 c.Agent.Temperature = temperature
3174 c.Agent.MaxSteps = 0
3175 c.Agent.PlannerMaxSteps = 0
3176 c.Agent.SystemPrompt = systemPrompt
3177 return nil
3178 })
3179 }
3180
3181 func (a *App) SetCompactRatio(ratio float64) error {
3182 _, err := a.applyConfigChangeWithWarning("context compaction threshold", func(c *config.Config) error {
3183 return c.SetCompactRatio(ratio)
3184 })
3185 return err
3186 }
3187
3188 func (a *App) SetReasoningLanguage(lang string) error {
3189 if err := a.ensureLiveControllersRuntimeMutationAllowed("reasoning language"); err != nil {
3190 return err
3191 }
3192 var cfg *config.Config
3193 // Lock only the load-modify-save cycle; the live-controller fan-out below
3194 // must not hold the config edit lock.
3195 if err := func() error {
3196 unlock := config.LockUserConfigEdits()
3197 defer unlock()
3198 loaded, path, err := a.loadDesktopUserConfigForEdit()
3199 if err != nil {
3200 return err
3201 }
3202 if err := loaded.SetReasoningLanguage(lang); err != nil {
3203 return err
3204 }
3205 if err := loaded.SaveTo(path); err != nil {
3206 return err
3207 }
3208 cfg = loaded
3209 return nil
3210 }(); err != nil {
3211 return err
3212 }
3213 a.applyReasoningLanguageToLiveControllers(cfg.ReasoningLanguage())
3214 return nil
3215 }
3216
3217 func (a *App) applyReasoningLanguageToLiveControllers(fallback string) {
3218 type liveTab struct {
3219 root string
3220 ctrl control.SessionAPI
3221 }
3222 var tabs []liveTab
3223 a.mu.RLock()
3224 for _, tab := range a.tabs {
3225 if tab != nil && tab.Ctrl != nil {
3226 tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
3227 }
3228 }
3229 a.mu.RUnlock()
3230 for _, tab := range tabs {
3231 mode := fallback
3232 if cfg, err := config.LoadForRoot(tab.root); err == nil {
3233 mode = cfg.ReasoningLanguage()
3234 }
3235 tab.ctrl.SetReasoningLanguage(mode)
3236 }
3237 }
3238
3239 func (a *App) applyResponseLanguageToLiveControllers(fallback string) {
3240 type liveTab struct {
3241 root string
3242 ctrl control.SessionAPI
3243 }
3244 var tabs []liveTab
3245 a.mu.RLock()
3246 for _, tab := range a.tabs {
3247 if tab != nil && tab.Ctrl != nil {
3248 tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
3249 }
3250 }
3251 a.mu.RUnlock()
3252 for _, tab := range tabs {
3253 mode := fallback
3254 if cfg, err := config.LoadForRoot(tab.root); err == nil {
3255 mode = cfg.ResponseLanguage()
3256 }
3257 tab.ctrl.SetResponseLanguage(mode)
3258 }
3259 }
3260
3261 // trimList drops blank entries from a string slice (and returns a non-nil slice).
3262 func trimList(in []string) []string {
3263 out := []string{}
3264 for _, s := range in {
3265 if t := strings.TrimSpace(s); t != "" {
3266 out = append(out, t)
3267 }
3268 }
3269 return out
3270 }
3271
3272 // SetConnectionKey detaches a legacy shared credential before updating this connection.
3273 // Empty values disable authentication for this connection without deleting another key.
3274 func (a *App) SetConnectionKey(name, value string) (string, error) {
3275 return a.applyModelConfigChangeWithWarning("provider key", func(c *config.Config) error { return setConnectionCredentialConfig(c, name, value) })
3276 }
3277
3278 // AddProviderConnection copies a preset or existing connection without sharing its credential.
3279 func (a *App) AddProviderConnection(presetID, sourceName, key string) (string, error) {
3280 return a.addProviderConnection(presetID, sourceName, key, "", "")
3281 }
3282
3283 // AddProviderConnectionWithURL overrides only the new connection, never the preset.
3284 func (a *App) AddProviderConnectionWithURL(presetID, sourceName, key, baseURL string) (string, error) {
3285 baseURL = strings.TrimSpace(baseURL)
3286 u, err := url.Parse(baseURL)
3287 if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil {
3288 return "", fmt.Errorf("invalid provider base URL")
3289 }
3290 return a.addProviderConnection(presetID, sourceName, key, baseURL, "")
3291 }
3292
3293 // AddProviderConnectionWithOptions applies overrides to the new connection only.
3294 func (a *App) AddProviderConnectionWithOptions(presetID, sourceName, key, baseURL, kind string) (string, error) {
3295 if kind != "" && kind != "openai" && kind != "responses" && kind != "anthropic" {
3296 return "", fmt.Errorf("invalid provider protocol")
3297 }
3298 baseURL = strings.TrimSpace(baseURL)
3299 if baseURL != "" {
3300 u, err := url.Parse(baseURL)
3301 if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil {
3302 return "", fmt.Errorf("invalid provider base URL")
3303 }
3304 }
3305 return a.addProviderConnection(presetID, sourceName, key, baseURL, kind)
3306 }
3307
3308 func (a *App) addProviderConnection(presetID, sourceName, key, baseURL, kind string) (string, error) {
3309 return a.applyModelConfigChangeWithWarning("provider access", func(c *config.Config) error {
3310 return addProviderConnectionConfig(c, presetID, sourceName, key, baseURL, kind)
3311 })
3312 }
3313
3314 func addProviderConnectionConfig(c *config.Config, presetID, sourceName, key, baseURL, kind string) error {
3315 if kind != "" && kind != "openai" && kind != "responses" && kind != "anthropic" {
3316 return fmt.Errorf("invalid provider protocol")
3317 }
3318 if baseURL != "" {
3319 u, err := url.Parse(baseURL)
3320 if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.Fragment != "" {
3321 return fmt.Errorf("invalid provider base URL")
3322 }
3323 }
3324 var connectionID [16]byte
3325 if _, err := rand.Read(connectionID[:]); err != nil {
3326 return err
3327 }
3328 entries, catalog, err := providerConnectionTemplate(c, presetID, sourceName)
3329 if err != nil {
3330 return err
3331 }
3332 endpoints := config.ProtocolEndpointsForCatalog(catalog)
3333 var prepared []config.ProviderEntry
3334 for _, entry := range entries {
3335 applyConnectionOverrides(&entry, kind, baseURL, endpoints)
3336 originalName := entry.Name
3337 entry.Name = fmt.Sprintf("%s-%x", originalName, connectionID)
3338 if entry.DisplayName == "" {
3339 entry.DisplayName = originalName
3340 }
3341 count := 1
3342 for _, existing := range c.Providers {
3343 if existing.DisplayName == entry.DisplayName || strings.HasPrefix(existing.DisplayName, entry.DisplayName+" · ") {
3344 count++
3345 }
3346 if existing.Name == entry.Name {
3347 return fmt.Errorf("connection identifier collision")
3348 }
3349 }
3350 if sourceName != "" || count > 1 {
3351 entry.DisplayName = fmt.Sprintf("%s · %d", entry.DisplayName, count)
3352 }
3353 if sourceName != "" {
3354 entry.Headers = nil
3355 } // Custom headers may contain credentials.
3356 entry.APIKeyEnv = fmt.Sprintf("REASONIX_CONNECTION_%X_%X_KEY", connectionID, []byte(originalName))
3357 if err := c.UpsertProvider(entry); err != nil {
3358 return err
3359 }
3360 addProviderAccess(c, entry.Name)
3361 prepared = append(prepared, entry)
3362 }
3363 // Validate every entry before the first credential write.
3364 for _, entry := range prepared {
3365 env, err := c.StageModelCredentialLocked(key)
3366 if err != nil {
3367 return err
3368 }
3369 p, _ := c.Provider(entry.Name)
3370 p.APIKeyEnv = env
3371 }
3372 return nil
3373 }
3374
3375 func applyConnectionOverrides(entry *config.ProviderEntry, kind, baseURL string, endpoints map[string]config.ProviderProtocolEndpoint) {
3376 if kind != "" && kind != entry.Kind {
3377 entry.Kind = kind
3378 entry.RequestURL = ""
3379 entry.ChatURL = ""
3380 entry.ModelsURL = ""
3381 entry.ExtraBody = nil
3382 entry.AuthHeader = false
3383 entry.Thinking = ""
3384 entry.Effort = ""
3385 entry.ResponsesMode = ""
3386 entry.ResponsesStateful = nil
3387 }
3388 if baseURL != "" {
3389 entry.BaseURL = baseURL
3390 entry.RequestURL = ""
3391 entry.ChatURL = ""
3392 entry.ModelsURL = ""
3393 }
3394 if endpoint, ok := endpoints[entry.Kind]; ok && strings.TrimRight(entry.BaseURL, "/") == strings.TrimRight(endpoint.BaseURL, "/") {
3395 // Only set affirmative catalog options; don't erase preset defaults.
3396 if endpoint.AuthHeader {
3397 entry.AuthHeader = true
3398 }
3399 if endpoint.ResponsesMode != "" {
3400 entry.ResponsesMode = endpoint.ResponsesMode
3401 }
3402 }
3403 }
3404
3405 func providerConnectionTemplate(c *config.Config, presetID, sourceName string) ([]config.ProviderEntry, config.ProviderCatalog, error) {
3406 var entries []config.ProviderEntry
3407 var catalog config.ProviderCatalog
3408 if presetID != "" {
3409 preset, ok := config.CuratedProviderPreset(presetID)
3410 if !ok {
3411 return nil, catalog, fmt.Errorf("unknown preset %q", presetID)
3412 }
3413 catalog = config.CatalogForProviderPreset(preset)
3414 entries = append(entries, preset.Entries...)
3415 for i := range entries {
3416 if entries[i].DisplayName == "" {
3417 entries[i].DisplayName = preset.Label
3418 }
3419 }
3420 } else {
3421 for _, p := range c.Providers {
3422 if p.Name == sourceName {
3423 entries = append(entries, p)
3424 break
3425 }
3426 }
3427 }
3428 if len(entries) == 0 && presetID == "" {
3429 for _, p := range config.Default().Providers {
3430 if p.Name == sourceName {
3431 entries = append(entries, p)
3432 break
3433 }
3434 }
3435 }
3436 if len(entries) == 0 {
3437 return nil, catalog, fmt.Errorf("connection template not found")
3438 }
3439 if presetID == "" {
3440 // The built-in official connection is the DeepSeek catalog.
3441 if sourceName == "deepseek-flash" || sourceName == "deepseek-pro" {
3442 catalog = config.ProviderCatalog{BrandID: "deepseek", Region: "global", Product: "api"}
3443 }
3444 }
3445 return entries, catalog, nil
3446 }
3447
3447 lines GO