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