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