返回 DeepSeek-Reasonix
model_settings_api.go
根目录 / desktop / model_settings_api.go
1 package main
2
3 import (
4 "encoding/hex"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "reflect"
10 "strings"
11
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/secrets"
15 )
16
17 // ModelSettingsChange is a closed set of model operations, never a replacement
18 // Config. Pointer fields distinguish omission from an explicit clear/false.
19 type ModelSettingsChange struct {
20 Kind string `json:"kind"`
21 RequestID string `json:"requestId"`
22 ExpectedFingerprint string `json:"expectedFingerprint"`
23 Field string `json:"field,omitempty"`
24 Ref string `json:"ref,omitempty"`
25 Name string `json:"name,omitempty"`
26 PresetID string `json:"presetId,omitempty"`
27 BaseURL string `json:"baseURL,omitempty"`
28 Protocol string `json:"protocol,omitempty"`
29 Names []string `json:"names,omitempty"`
30 Provider *ProviderView `json:"provider,omitempty"`
31 Key *string `json:"key,omitempty"`
32 Enabled *bool `json:"enabled,omitempty"`
33 Number int `json:"number,omitempty"`
34 Catalogs []ProviderModelCatalogUpdate `json:"catalogs,omitempty"`
35 }
36
37 type ModelSettingsIssue struct {
38 Code string `json:"code"`
39 Message string `json:"message"`
40 }
41
42 type ModelSettingsTarget struct {
43 Details *ModelApplicationDetails `json:"details,omitempty"`
44 TabID string `json:"tabId"`
45 Title string `json:"title,omitempty"`
46 Application string `json:"application"`
47 AppliedRevision string `json:"appliedRevision"`
48 DesiredRevision string `json:"desiredRevision"`
49 }
50
51 type ModelSettingsResult struct {
52 RequestID string `json:"requestId"`
53 Persisted bool `json:"persisted"`
54 Revision string `json:"revision"`
55 Application string `json:"application"`
56 Targets []ModelSettingsTarget `json:"targets"`
57 Issues []ModelSettingsIssue `json:"issues"`
58 AppliedCatalogs []string `json:"appliedCatalogs"`
59 }
60
61 type modelSettingsReceipt struct {
62 digest string
63 result ModelSettingsResult
64 }
65
66 func emptyModelSettingsResult() ModelSettingsResult {
67 return ModelSettingsResult{Application: "not_required", Targets: []ModelSettingsTarget{}, Issues: []ModelSettingsIssue{}, AppliedCatalogs: []string{}}
68 }
69
70 func modelSettingsEditFingerprint(c *config.Config) string {
71 // Default participates in editing concurrency, but not runtime freshness.
72 return hex.EncodeToString([]byte(providerRemovalStateFingerprint(c, c.ModelRuntimeFingerprint(c.DefaultModel)+providerCredentialsRevision())))
73 }
74
75 func modelSettingsIssue(code string, err error) ModelSettingsIssue {
76 var pathErr *os.PathError
77 if errors.As(err, &pathErr) {
78 return ModelSettingsIssue{Code: code, Message: "The configuration file could not be read or written. Check file access and available disk space."}
79 }
80 return ModelSettingsIssue{Code: code, Message: secrets.RedactCredentials(err.Error())}
81 }
82
83 // ApplyModelSettings checks the edit precondition under the SAME file lock as
84 // the mutation. Replaying an old request cannot silently overwrite newer state.
85 func (a *App) ApplyModelSettings(change ModelSettingsChange) (result ModelSettingsResult) {
86 a.modelSettingsSubmitMu.Lock()
87 defer a.modelSettingsSubmitMu.Unlock()
88 result = emptyModelSettingsResult()
89 result.RequestID = change.RequestID
90 raw, marshalErr := json.Marshal(change)
91 if marshalErr != nil {
92 result.Issues = append(result.Issues, modelSettingsIssue("validation", marshalErr))
93 return result
94 }
95 digest, digestErr := config.ModelSettingsRequestDigest(raw)
96 if digestErr != nil {
97 result.Issues = append(result.Issues, modelSettingsIssue("save_failed", digestErr))
98 return result
99 }
100 if receipt, ok := a.modelSettingsReceipts[change.RequestID]; ok {
101 if receipt.digest != digest {
102 result.Issues = append(result.Issues, ModelSettingsIssue{Code: "request_conflict", Message: "requestId was already used for a different edit"})
103 return result
104 }
105 return receipt.result
106 }
107 if receipt, ok := config.LookupModelSettingsReceipt(change.RequestID); ok {
108 if receipt.RequestDigest != digest {
109 if !strings.HasPrefix(receipt.RequestDigest, "hmac-v1:") {
110 result.Issues = append(result.Issues, ModelSettingsIssue{Code: "unknown_result", Message: "This older receipt cannot verify the request contents. Reload current settings."})
111 return result
112 }
113 result.Issues = append(result.Issues, ModelSettingsIssue{Code: "request_conflict", Message: "requestId was already used for a different edit"})
114 return result
115 }
116 result.Persisted = true
117 result.Revision = receipt.ResultRevision
118 if result.Revision == "" {
119 result.Revision = receipt.AfterRevision
120 }
121 status := a.GetModelSettingsApplication()
122 result.Application, result.Targets, result.Issues = status.Application, status.Targets, status.Issues
123 return result
124 }
125 defer func() {
126 if change.RequestID == "" {
127 return
128 }
129 if a.modelSettingsReceipts == nil {
130 a.modelSettingsReceipts = map[string]modelSettingsReceipt{}
131 }
132 a.modelSettingsReceipts[change.RequestID] = modelSettingsReceipt{digest, result}
133 a.modelSettingsReceiptOrder = append(a.modelSettingsReceiptOrder, change.RequestID)
134 if len(a.modelSettingsReceiptOrder) > 128 {
135 delete(a.modelSettingsReceipts, a.modelSettingsReceiptOrder[0])
136 a.modelSettingsReceiptOrder = a.modelSettingsReceiptOrder[1:]
137 }
138 }()
139 if err := validateModelSettingsFields(change); err != nil {
140 result.Issues = append(result.Issues, modelSettingsIssue("validation", err))
141 return result
142 }
143 if strings.TrimSpace(change.RequestID) == "" || strings.TrimSpace(change.ExpectedFingerprint) == "" {
144 result.Issues = append(result.Issues, modelSettingsIssue("validation", fmt.Errorf("requestId and expectedFingerprint are required; reload settings before saving")))
145 return result
146 }
147 err := func() error {
148 unlock := config.LockUserConfigEdits()
149 defer unlock()
150 // Every edit precondition includes credential state. Keep external
151 // credential-only writers outside compare, stage and config commit.
152 unlockCredentials, err := config.LockUserCredentialEdits()
153 if err != nil {
154 return err
155 }
156 defer unlockCredentials()
157 c, path, err := a.loadDesktopUserConfigForEdit()
158 if err != nil {
159 return err
160 }
161 if err := c.BeginModelCredentialCommitLocked(path, change.RequestID, digest); err != nil {
162 return err
163 }
164 defer c.CleanupStagedModelCredentialsLocked(path)
165 // Recovery and another process may have published a receipt while this
166 // request waited for the config lock. Deduplicate again under both locks.
167 if receipt, ok := config.LookupModelSettingsReceipt(change.RequestID); ok {
168 if receipt.RequestDigest != digest {
169 return fmt.Errorf("request_conflict: requestId was already used for a different edit")
170 }
171 result.Persisted, result.Revision = true, receipt.ResultRevision
172 if result.Revision == "" {
173 result.Revision = receipt.AfterRevision
174 }
175 return nil
176 }
177 result.Revision = modelSettingsEditFingerprint(c)
178 if result.Revision != change.ExpectedFingerprint {
179 return fmt.Errorf("model settings changed; reload and review the current values before saving")
180 }
181 baseline := c.ModelSettingsBaseline()
182 if err := applyModelSettingsChange(c, change, &result); err != nil {
183 return err
184 }
185 if change.Kind == "protocol_upgrade" {
186 var changed bool
187 changed, err = c.UpgradeDeepSeekProviderProtocolLocked(path, change.Name)
188 if err == nil && !changed {
189 err = fmt.Errorf("provider is not eligible for protocol upgrade")
190 }
191 } else if change.Kind == "preference" && change.Field == "search" {
192 err = c.SaveWebSearchModelTo(path)
193 } else {
194 err = c.SaveModelSettingsTo(path, baseline)
195 }
196 if err != nil {
197 return err
198 }
199 result.Persisted = true
200 if saved, _, readErr := a.loadDesktopUserConfigForView(); readErr == nil {
201 result.Revision = modelSettingsEditFingerprint(saved)
202 } else {
203 return readErr
204 }
205 result.Persisted = true
206 if err := c.MarkModelCredentialConfigCommittedLocked(path, result.Revision); err != nil {
207 return err
208 }
209 if err := c.CompleteModelCredentialCommitLocked(); err != nil {
210 return err
211 }
212 return nil
213 }()
214 if err != nil {
215 result.Issues = append(result.Issues, modelSettingsIssue("save_failed", err))
216 return result
217 }
218 a.modelSettingsSaved(change.Kind)
219 status := a.GetModelSettingsApplication()
220 result.Application, result.Targets, result.Issues = status.Application, status.Targets, status.Issues
221 return result
222 }
223
224 // GetModelSettingsRequest recovers a known result after a bridge interruption.
225 // No receipt after restart/eviction means unknown; callers must not infer that
226 // a write failed or automatically repeat it.
227 func (a *App) GetModelSettingsRequest(requestID string) ModelSettingsResult {
228 a.modelSettingsSubmitMu.Lock()
229 receipt, ok := a.modelSettingsReceipts[requestID]
230 a.modelSettingsSubmitMu.Unlock()
231 if !ok {
232 if durable, found := config.RecoverModelSettingsReceipt(requestID); found {
233 result := emptyModelSettingsResult()
234 result.RequestID = requestID
235 result.Persisted = true
236 result.Revision = durable.ResultRevision
237 if result.Revision == "" {
238 result.Revision = durable.AfterRevision
239 }
240 status := a.GetModelSettingsApplication()
241 result.Application, result.Targets, result.Issues = status.Application, status.Targets, status.Issues
242 return result
243 }
244 result := emptyModelSettingsResult()
245 result.RequestID = requestID
246 result.Issues = append(result.Issues, ModelSettingsIssue{Code: "unknown_result", Message: "The save result could not be confirmed. Review the current settings before saving again."})
247 return result
248 }
249 result := receipt.result
250 if result.Persisted {
251 status := a.GetModelSettingsApplication()
252 result.Application, result.Targets, result.Issues = status.Application, status.Targets, status.Issues
253 }
254 return result
255 }
256
257 func applyModelSettingsChange(c *config.Config, change ModelSettingsChange, result *ModelSettingsResult) error {
258 switch change.Kind {
259 case "preference":
260 return applyModelPreference(c, change)
261 case "provider_save":
262 if change.Provider == nil {
263 return fmt.Errorf("provider is required")
264 }
265 if err := saveProviderConfig(c, *change.Provider); err != nil {
266 return err
267 }
268 if change.Key != nil {
269 return setConnectionCredentialConfig(c, change.Provider.Name, *change.Key)
270 }
271 return nil
272 case "credential":
273 if change.Key == nil {
274 return fmt.Errorf("key is required")
275 }
276 if change.Name != "" && len(change.Names) != 0 {
277 return fmt.Errorf("use either name or names for a credential edit")
278 }
279 names := change.Names
280 if change.Name != "" {
281 names = []string{change.Name}
282 }
283 return setConnectionsCredentialConfig(c, names, *change.Key)
284 case "web_search_capability":
285 if change.Enabled == nil {
286 return fmt.Errorf("enabled is required")
287 }
288 return setProviderWebSearchConfig(c, change.Names, *change.Enabled)
289 case "connection_add":
290 return addProviderConnectionConfig(c, change.PresetID, change.Name, modelSettingsKey(change), change.BaseURL, change.Protocol)
291 case "official_add":
292 return addOfficialProviderAccessConfig(c, change.Name, modelSettingsKey(change))
293 case "preset_add":
294 return addProviderPresetConfig(c, change.PresetID, modelSettingsKey(change))
295 case "preset_reset":
296 return resetProviderPresetConfig(c, change.PresetID)
297 case "protocol_upgrade":
298 if change.Name == "" {
299 return fmt.Errorf("provider name is required")
300 }
301 return nil
302 case "catalogs":
303 revision := providerCredentialsRevision()
304 for _, update := range change.Catalogs {
305 changed, err := applyProviderModelCatalogUpdate(c, update, revision)
306 if err != nil {
307 return err
308 }
309 if changed {
310 result.AppliedCatalogs = append(result.AppliedCatalogs, update.Name)
311 }
312 }
313 return nil
314 case "provider_remove", "access_remove":
315 return applyModelProviderRemoval(c, change)
316 case "rename":
317 if strings.TrimSpace(change.Ref) == "" {
318 return fmt.Errorf("display name is required")
319 }
320 for _, name := range change.Names {
321 p, ok := c.Provider(name)
322 if !ok {
323 return fmt.Errorf("unknown provider %q", name)
324 }
325 entry := *p
326 entry.DisplayName = strings.TrimSpace(change.Ref)
327 if err := c.UpsertProvider(entry); err != nil {
328 return err
329 }
330 }
331 return nil
332 default:
333 return fmt.Errorf("unknown model setting operation %q", change.Kind)
334 }
335 }
336
337 func applyModelPreference(c *config.Config, change ModelSettingsChange) error {
338 if change.Provider != nil || change.Key != nil || len(change.Catalogs) > 0 || change.Enabled != nil {
339 return fmt.Errorf("unexpected preference fields")
340 }
341 switch change.Field {
342 case "default":
343 return setDefaultModelConfig(c, change.Ref)
344 case "planner":
345 return setPlannerModelConfig(c, change.Ref)
346 case "vision":
347 return setVisionModelConfig(c, change.Ref)
348 case "subagent":
349 return setSubagentModelConfig(c, change.Ref)
350 case "subagent_effort":
351 return setSubagentEffortConfig(c, change.Ref)
352 case "profile_model":
353 return setSubagentProfileModelConfig(c, change.Name, change.Ref)
354 case "profile_effort":
355 return setSubagentProfileEffortConfig(c, change.Name, change.Ref)
356 case "depth":
357 return setMaxSubagentDepthConfig(c, change.Number)
358 case "concurrency":
359 return setMaxSubagentConcurrencyConfig(c, change.Number)
360 case "writers":
361 return setMaxParallelWritersConfig(c, change.Number)
362 case "search":
363 return setWebSearchModelConfig(c, change.Ref)
364 default:
365 return fmt.Errorf("unknown model preference %q", change.Field)
366 }
367 }
368
369 func applyModelProviderRemoval(c *config.Config, change ModelSettingsChange) error {
370 names := uniqueNonEmptyStrings(change.Names)
371 if len(names) == 0 {
372 return fmt.Errorf("provider names are required")
373 }
374 for _, name := range names {
375 if _, ok := c.Provider(name); !ok {
376 return fmt.Errorf("unknown provider %q", name)
377 }
378 }
379 removeEntries := change.Kind == "provider_remove"
380 if change.Kind == "access_remove" {
381 p, _ := c.Provider(names[0])
382 removeEntries = !isOfficialBuiltInProvider(*p)
383 if removeEntries && len(names) > 1 && !isAtomicCustomProviderGroup(c, names) {
384 return fmt.Errorf("custom providers do not belong to one removable group")
385 }
386 if !removeEntries {
387 if err := validateOfficialProviderRemoval(c, names); err != nil {
388 return err
389 }
390 names = officialProviderRemovalTargets(names)
391 }
392 }
393 fallback := providerAccessFallbackRef(c, names)
394 retargetProviderReferences(c, names, fallback)
395 if removeEntries {
396 for _, name := range names {
397 p, _ := c.Provider(name)
398 if isOfficialBuiltInProvider(*p) {
399 return fmt.Errorf("remove access for an official provider instead")
400 }
401 if err := c.RemoveProvider(name); err != nil {
402 return err
403 }
404 }
405 }
406 removeProviderAccess(c, names...)
407 return nil
408 }
409
410 func validateModelSettingsFields(change ModelSettingsChange) error {
411 allowed := ""
412 switch change.Kind {
413 case "preference":
414 switch change.Field {
415 case "depth", "concurrency", "writers":
416 allowed = "Field Number"
417 case "profile_model", "profile_effort":
418 allowed = "Field Name Ref"
419 default:
420 allowed = "Field Ref"
421 }
422 case "provider_save":
423 allowed = "Provider Key"
424 case "credential":
425 allowed = "Name Names Key"
426 case "web_search_capability":
427 allowed = "Names Enabled"
428 case "connection_add":
429 allowed = "PresetID Name Key BaseURL Protocol"
430 case "official_add":
431 allowed = "Name Key"
432 case "preset_add":
433 allowed = "PresetID Key"
434 case "preset_reset":
435 allowed = "PresetID"
436 case "protocol_upgrade":
437 allowed = "Name"
438 case "catalogs":
439 allowed = "Catalogs"
440 case "provider_remove", "access_remove":
441 allowed = "Names"
442 case "rename":
443 allowed = "Names Ref"
444 default:
445 return fmt.Errorf("unknown model setting operation %q", change.Kind)
446 }
447 allowed = " Kind RequestID ExpectedFingerprint " + allowed + " "
448 value, typ := reflect.ValueOf(change), reflect.TypeOf(change)
449 for i := range value.NumField() {
450 if !value.Field(i).IsZero() && !strings.Contains(allowed, " "+typ.Field(i).Name+" ") {
451 return fmt.Errorf("unexpected field %s for %s", typ.Field(i).Tag.Get("json"), change.Kind)
452 }
453 }
454 return nil
455 }
456
457 func modelSettingsKey(change ModelSettingsChange) string {
458 if change.Key == nil {
459 return ""
460 }
461 return *change.Key
462 }
463
464 func setConnectionCredentialConfig(c *config.Config, name, key string) error {
465 return setConnectionsCredentialConfig(c, []string{name}, key)
466 }
467
468 func setConnectionsCredentialConfig(c *config.Config, names []string, key string) error {
469 if len(names) == 0 {
470 return fmt.Errorf("at least one provider is required")
471 }
472 entries := make([]config.ProviderEntry, 0, len(names))
473 for _, name := range names {
474 p, ok := c.Provider(name)
475 if !ok {
476 return fmt.Errorf("unknown provider %q", name)
477 }
478 entries = append(entries, *p)
479 }
480 env, err := c.RotateModelCredentialLocked(names, key)
481 if err != nil {
482 return err
483 }
484 for _, entry := range entries {
485 entry.APIKeyEnv = env
486 if err := c.UpsertProvider(entry); err != nil {
487 return err
488 }
489 }
490 return nil
491 }
492
493 // Status is derived from the owning runtime and current disk config; it cannot
494 // be lost by an event race, app restart or changing the active tab.
495 func (a *App) GetModelSettingsApplication() ModelSettingsResult {
496 result := emptyModelSettingsResult()
497 result.Persisted = true
498 if cfg, _, err := a.loadDesktopUserConfigForView(); err == nil {
499 result.Revision = modelSettingsEditFingerprint(cfg)
500 } else {
501 result.Application = "failed"
502 result.Issues = append(result.Issues, modelSettingsIssue("read_failed", err))
503 }
504 a.mu.RLock()
505 tabs := append([]*WorkspaceTab(nil), a.runtimeTabsLocked()...)
506 controllers := make([]modelSettingsSnapshot, len(tabs))
507 failures := make([]*modelSettingsApplyFailure, len(tabs))
508 titles := make([]string, len(tabs))
509 for i, tab := range tabs {
510 if tab != nil {
511 controllers[i], _ = tab.Ctrl.(modelSettingsSnapshot)
512 failures[i] = tab.modelApplication.failure
513 titles[i] = tab.TopicTitle
514 if titles[i] == "" {
515 titles[i] = tab.Label
516 }
517 }
518 }
519 a.mu.RUnlock()
520 for i, tab := range tabs {
521 if tab == nil {
522 continue
523 }
524 snapshot := controllers[i]
525 if snapshot == nil {
526 continue
527 }
528 applied, desired, err := snapshot.ModelSettingsState()
529 state := "applied"
530 if err != nil {
531 state = "failed"
532 result.Application = "failed"
533 result.Issues = append(result.Issues, modelSettingsIssue("read_failed", err))
534 } else if applied != desired {
535 state = "pending"
536 if failure := failures[i]; failure != nil && failure.revision == desired {
537 state = "failed"
538 result.Application = "failed"
539 result.Issues = append(result.Issues, ModelSettingsIssue{Code: "apply_failed", Message: failure.message})
540 }
541 if result.Application != "failed" {
542 result.Application = "pending"
543 }
544 }
545 target := ModelSettingsTarget{TabID: tab.ID, Title: titles[i], Application: state, AppliedRevision: applied, DesiredRevision: desired}
546 if state != "applied" {
547 if ctrl, ok := snapshot.(control.SessionAPI); ok {
548 target.Details = modelApplicationDetails(ctrl)
549 if state == "failed" {
550 target.Details.Code = "model_settings_apply_failed"
551 }
552 }
553 }
554 result.Targets = append(result.Targets, target)
555 }
556 a.appendRemoteModelSettingsStatus(&result)
557 if result.Application == "not_required" && len(result.Targets) > 0 {
558 result.Application = "applied"
559 }
560 return result
561 }
562
563 func (a *App) RetryModelSettingsApplication(tabID string) ModelSettingsResult {
564 a.remoteTabMu.Lock()
565 remote := a.remoteTabs[tabID] != nil
566 a.remoteTabMu.Unlock()
567 if remote {
568 _, _, err := a.ensureRemoteModelSettings(tabID)
569 result := a.GetModelSettingsApplication()
570 if err != nil {
571 var application *modelApplicationError
572 if errors.As(err, &application) && application.details != nil && application.details.Code == "model_settings_pending" {
573 result.Application = "pending"
574 } else {
575 result.Application = "failed"
576 result.Issues = append(result.Issues, modelSettingsIssue("apply_failed", err))
577 }
578 }
579 return result
580 }
581 a.mu.Lock()
582 tab := a.tabByEventSinkIDLocked(tabID)
583 if tab != nil {
584 tab.modelApplication.failure = nil
585 }
586 a.mu.Unlock()
587 var err error
588 if tab == nil || tab.ID != tabID {
589 err = fmt.Errorf("session is no longer available")
590 } else {
591 err = a.refreshTabModelSettings(tab)
592 }
593 result := a.GetModelSettingsApplication()
594 if err != nil {
595 var busy *rebuildBusyError
596 if errors.As(err, &busy) {
597 result.Application = "pending"
598 } else {
599 result.Application = "failed"
600 result.Issues = append(result.Issues, modelSettingsIssue("apply_failed", err))
601 }
602 }
603 return result
604 }
605
605 lines GO