返回 DeepSeek-Reasonix
remote_model_settings.go
根目录 / desktop / remote_model_settings.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net/http"
13 "strings"
14 "time"
15
16 "reasonix/internal/config"
17 )
18
19 type remoteModelSettingsStatus struct {
20 config.ModelSettingsOwnership
21 Version int `json:"version"`
22 Revision string `json:"revision"`
23 Model string `json:"model"`
24 SessionPath string `json:"sessionPath"`
25 OwnedRevisions []string `json:"ownedRevisions"`
26 UnversionedOwners bool `json:"unversionedOwners"`
27 }
28
29 func credentialProxyScope(host, workspace string) string {
30 sum := sha256.Sum256(fmt.Appendf(nil, "%d:%s%s", len(host), host, workspace))
31 return hex.EncodeToString(sum[:])
32 }
33
34 // Status refreshes already run when foreground/background work settles. Read
35 // ownership under the manager gate, then reject receipts overtaken by source
36 // refreshes using the Serve-wide ownership sequence.
37 func (a *App) refreshRemoteModelOwnership(ctx context.Context, tabID string, client *http.Client, generation uint64) {
38 a.remoteMu.Lock()
39 manager, ok := a.remoteRuntime.(*desktopRemoteManager)
40 a.remoteMu.Unlock()
41 if !ok {
42 return
43 }
44 a.remoteTabMu.Lock()
45 tab := a.remoteTabs[tabID]
46 if tab == nil || tab.client != client || tab.gen != generation || tab.settings.revision == "" {
47 a.remoteTabMu.Unlock()
48 return
49 }
50 host, workspace, path := tab.ref.HostID, tab.ref.Workspace, tab.routing.currentPath
51 a.remoteTabMu.Unlock()
52 managed := manager.managed(host)
53 if managed == nil {
54 return
55 }
56 managed.serveMu.Lock()
57 defer managed.serveMu.Unlock()
58 if !manager.isCurrent(host, managed) {
59 return
60 }
61 manager.mu.Lock()
62 server := managed.serves[workspace]
63 base := ""
64 if server != nil {
65 base = server.view.LocalURL
66 }
67 manager.mu.Unlock()
68 if base == "" {
69 return
70 }
71 status, err := remoteModelSettingsRequest(ctx, client, base, path, nil)
72 if err == nil && manager.isCurrent(host, managed) {
73 if !a.reconcileCredentialProxyGenerations(host, workspace, status) {
74 return
75 }
76 // Serve may have applied a new snapshot itself for a browser or queued
77 // turn. Reflect that acknowledgement on the same bound Desktop target.
78 if cfg, loadErr := config.LoadModelRuntimeSnapshot("."); loadErr == nil {
79 for _, provider := range cfg.Providers {
80 for _, modelID := range provider.ModelList() {
81 ref := provider.Name + "/" + modelID
82 if remoteSnapshotProviderName(provider.Name, modelID)+"/"+modelID != status.Model || cfg.ModelRuntimeFingerprint(ref) != status.Revision {
83 continue
84 }
85 a.remoteTabMu.Lock()
86 if current := a.remoteTabs[tabID]; current == tab && current.client == client && current.gen == generation && current.routing.currentPath == path && status.SessionPath == path {
87 current.model, current.settings.revision = ref, status.Revision
88 current.settings.generation, current.settings.sessionPath = generation, path
89 current.settings.failure, current.settings.failureRevision = "", ""
90 }
91 a.remoteTabMu.Unlock()
92 }
93 }
94 }
95 }
96 }
97
98 // Each model has its own immutable tunnel token. Provider names are stable
99 // across revisions; only the virtual credentials and resolver content change.
100 func remoteSnapshotProviderName(provider, model string) string {
101 sum := sha256.Sum256([]byte(model))
102 return provider + "-" + hex.EncodeToString(sum[:6])
103 }
104
105 func (a *App) buildRemoteModelSettings(host, workspace, model string, remotePort int, cfg *config.Config) (*config.ModelRuntimeSettings, string, error) {
106 offerID, err := config.NewModelSettingsOfferID()
107 if err != nil {
108 return nil, "", err
109 }
110 return a.buildRemoteModelSettingsOffer(host, workspace, model, remotePort, cfg, offerID)
111 }
112
113 func (a *App) buildRemoteModelSettingsOffer(host, workspace, model string, remotePort int, cfg *config.Config, offerID string) (_ *config.ModelRuntimeSettings, _ string, buildErr error) {
114 defer func() {
115 if buildErr != nil {
116 a.finishCredentialProxyOffer(host, workspace, offerID)
117 }
118 }()
119 revision := cfg.ModelRuntimeFingerprint(model)
120 bundle := &config.ModelRuntimeSettings{OfferID: offerID, Revision: revision, ProxyURL: fmt.Sprintf("http://127.0.0.1:%d", remotePort), Credentials: map[string]string{}, Preferences: cfg.RuntimeModelPreferences()}
121 refs := map[string]string{}
122 for _, base := range cfg.Providers {
123 if !base.Configured() || !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, base.Name) {
124 continue
125 }
126 for _, modelID := range base.ModelList() {
127 ref := base.Name + "/" + modelID
128 entry, ok := cfg.ResolveModel(ref)
129 if !ok {
130 continue
131 }
132 route, err := a.applyCredentialProxySnapshot(host, workspace, ref, cfg, revision, offerID)
133 if err != nil {
134 return nil, "", err
135 }
136 p := *entry
137 p.Name = remoteSnapshotProviderName(base.Name, modelID)
138 p.ModelsURL, p.BalanceURL, p.APIKeyEnv = "", "", ""
139 p.Headers, p.ExtraBody = nil, nil
140 p.NoProxy = true
141 p.Model, p.Default, p.Models = modelID, "", nil
142 bundle.Providers = append(bundle.Providers, p)
143 bundle.Credentials[p.Name] = route.token
144 if ref == model {
145 bundle.SourceToken = route.token
146 }
147 refs[ref] = p.Name + "/" + modelID
148 if modelID == base.DefaultModel() {
149 refs[base.Name] = refs[ref]
150 }
151 }
152 }
153 mapRef := func(ref string) string {
154 if mapped := refs[ref]; mapped != "" {
155 return mapped
156 }
157 return ref
158 }
159 p := &bundle.Preferences
160 p.PlannerModel, p.VisionModel, p.WebSearchModel = mapRef(p.PlannerModel), mapRef(p.VisionModel), mapRef(p.WebSearchModel)
161 p.GuardianModel, p.RecoveryModel, p.SubagentModel = mapRef(p.GuardianModel), mapRef(p.RecoveryModel), mapRef(p.SubagentModel)
162 p.SubagentModels = map[string]string{}
163 for name, ref := range cfg.Agent.SubagentModels {
164 p.SubagentModels[name] = mapRef(ref)
165 }
166 if refs[model] == "" {
167 return nil, "", fmt.Errorf("no available remote model in saved settings")
168 }
169 bundle.References = refs
170 return bundle, refs[model], nil
171 }
172
173 func (a *App) finishCredentialProxyOffer(host, workspace, offerID string) {
174 if offerID == "" {
175 return
176 }
177 a.credProxyMu.Lock()
178 p := a.credProxy
179 a.credProxyMu.Unlock()
180 if p == nil {
181 return
182 }
183 p.mu.Lock()
184 defer p.mu.Unlock()
185 scope := credentialProxyScope(host, workspace)
186 for _, route := range p.routes {
187 if route.scope == scope {
188 delete(route.holds, offerID)
189 }
190 }
191 }
192
193 // updateMu serializes reservations; releasing one only needs the route lock.
194 // Refuse excess candidates without evicting a runtime or an accepted request.
195 func (p *credentialProxy) validateModelSettingsOfferCapacity(up proxyUpstream) error {
196 if up.offerID == "" {
197 return nil
198 }
199 p.mu.Lock()
200 defer p.mu.Unlock()
201 offers := map[string]bool{}
202 for _, route := range p.routes {
203 if route.scope != up.scope {
204 continue
205 }
206 if route.holds[up.offerID] {
207 return nil
208 }
209 for id := range route.holds {
210 offers[id] = true
211 }
212 }
213 if len(offers) >= 64 {
214 return fmt.Errorf("too many unacknowledged model settings offers for this session")
215 }
216 return nil
217 }
218
219 // A managed Serve can refresh at its own run boundary, including durable
220 // inbox dispatch. Authentication is the currently owned immutable route token;
221 // the request cannot choose a local workspace or disclose a real credential.
222 func (a *App) serveModelSettingsSource(w http.ResponseWriter, r *http.Request, route *credProxyRoute) {
223 if r.Method != http.MethodPost {
224 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
225 return
226 }
227 var request config.ModelSettingsSourceRequest
228 decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
229 decoder.DisallowUnknownFields()
230 if err := decoder.Decode(&request); err != nil || len(request.OfferID) != 32 || (request.Mode != "prepare" && request.Mode != "finish") {
231 http.Error(w, "invalid model settings request", http.StatusBadRequest)
232 return
233 }
234 if _, err := hex.DecodeString(request.OfferID); err != nil {
235 http.Error(w, "invalid model settings offer", http.StatusBadRequest)
236 return
237 }
238 offers := []string{request.PreviousOfferID}
239 if request.Mode == "finish" {
240 offers = append(offers, request.OfferID)
241 }
242 if !a.reconcileCredentialProxyGenerations(route.host, route.workspace, remoteModelSettingsStatus{ModelSettingsOwnership: request.ModelSettingsOwnership, Version: 1, OwnedRevisions: request.OwnedRevisions, UnversionedOwners: request.UnversionedOwners}, offers...) {
243 http.Error(w, "stale model settings ownership", http.StatusConflict)
244 return
245 }
246 cfg, err := config.LoadModelRuntimeSnapshot(".")
247 if err != nil {
248 http.Error(w, "cannot read saved model settings", http.StatusServiceUnavailable)
249 return
250 }
251 model := request.Model
252 if model == "" {
253 model = route.ref
254 }
255 model, err = resolveModelSettingsRuntime(cfg, model)
256 if err != nil {
257 http.Error(w, "choose an available model in Desktop Settings before starting another run", http.StatusConflict)
258 return
259 }
260 response := config.ModelSettingsSourceResponse{Version: 1, Revision: cfg.ModelRuntimeFingerprint(model)}
261 if request.Mode == "prepare" && request.AppliedRevision != response.Revision {
262 if request.RemotePort < 1 || request.RemotePort > 65535 {
263 http.Error(w, "invalid credential tunnel port", http.StatusBadRequest)
264 return
265 }
266 response.Settings, response.Ref, err = a.buildRemoteModelSettingsOffer(route.host, route.workspace, model, request.RemotePort, cfg, request.OfferID)
267 if err != nil {
268 http.Error(w, "cannot prepare saved model settings", http.StatusConflict)
269 return
270 }
271 if !a.reserveCredentialProxyInstall(route.host, route.workspace, request.OfferID, response.Revision, request.OwnershipIncarnation) {
272 a.finishCredentialProxyOffer(route.host, route.workspace, request.OfferID)
273 http.Error(w, "model settings owner was replaced", http.StatusConflict)
274 return
275 }
276 }
277 w.Header().Set("Content-Type", "application/json")
278 _ = json.NewEncoder(w).Encode(response)
279 }
280
281 const remoteModelSettingsUpgradeHint = "saved model settings require a newer remote Serve; upgrade or safely reconnect after its current work finishes"
282
283 type remoteModelSettingsRejection struct {
284 message string
285 // unsupported marks rejections that mean the remote Serve predates the
286 // model-settings protocol entirely (no /model-settings route at all).
287 unsupported bool
288 }
289
290 func (e *remoteModelSettingsRejection) Error() string { return e.message }
291
292 // isRemoteModelSettingsUnsupported reports whether err means the remote Serve
293 // cannot speak the model-settings protocol, as opposed to refusing a specific
294 // snapshot (ownership conflicts, busy turns, and other transient rejections).
295 func isRemoteModelSettingsUnsupported(err error) bool {
296 var rejected *remoteModelSettingsRejection
297 return errors.As(err, &rejected) && rejected.unsupported
298 }
299
300 func remoteModelSettingsRequest(ctx context.Context, client *http.Client, base, expectedPath string, body any) (remoteModelSettingsStatus, error) {
301 var result remoteModelSettingsStatus
302 method := http.MethodGet
303 var data []byte
304 if body != nil {
305 method = http.MethodPost
306 var err error
307 data, err = json.Marshal(body)
308 if err != nil {
309 return result, err
310 }
311 }
312 req, err := http.NewRequestWithContext(ctx, method, serveURL(base, "/model-settings"), bytes.NewReader(data))
313 if err != nil {
314 return result, err
315 }
316 req.Header.Set("Content-Type", "application/json")
317 if expectedPath != "" {
318 req.Header.Set(expectedSessionPathHeader, expectedPath)
319 }
320 resp, err := client.Do(req)
321 if err != nil {
322 return result, err
323 }
324 defer resp.Body.Close()
325 if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
326 return result, &remoteModelSettingsRejection{message: remoteModelSettingsUpgradeHint, unsupported: true}
327 }
328 if resp.StatusCode != http.StatusOK {
329 detail, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
330 return result, &remoteModelSettingsRejection{message: fmt.Sprintf("remote model settings status %d: %s", resp.StatusCode, strings.TrimSpace(string(detail)))}
331 }
332 payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
333 if err != nil {
334 return result, err
335 }
336 if err := json.Unmarshal(payload, &result); err != nil {
337 // Older Serves answer unknown routes with the HTML index; classify that
338 // document response as an unsupported model-settings protocol.
339 if trimmed := bytes.TrimSpace(payload); len(trimmed) > 0 && trimmed[0] == '<' {
340 return result, &remoteModelSettingsRejection{message: remoteModelSettingsUpgradeHint, unsupported: true}
341 }
342 return result, fmt.Errorf("decode remote model settings status: %w", err)
343 }
344 if result.Version != 1 {
345 return result, fmt.Errorf("remote Serve does not support immutable model settings")
346 }
347 return result, nil
348 }
349
350 // ensureRemoteModelSettings is the Desktop remote run-admission boundary.
351 // Approval/ask/steer replies bypass it because they belong to the accepted run.
352 // The returned generation scopes the admission (revision or legacy skip) to one
353 // tab generation; callers must re-admit before a target that no longer runs it.
354 func (a *App) ensureRemoteModelSettings(tabID string) (string, uint64, error) {
355 if !a.remoteTabLocalProxy(tabID) {
356 return "", 0, nil
357 }
358 for {
359 cfg, err := config.LoadModelRuntimeSnapshot(".")
360 if err != nil {
361 return "", 0, err
362 }
363 a.remoteTabMu.Lock()
364 tab := a.remoteTabs[tabID]
365 if tab == nil {
366 a.remoteTabMu.Unlock()
367 return "", 0, fmt.Errorf("remote session is no longer available")
368 }
369 model, applied, generation, path := tab.model, tab.settings.revision, tab.gen, tab.routing.currentPath
370 valid := tab.settings.generation == generation && tab.settings.sessionPath == path
371 // Generation 0 is an unrecorded verdict, not a legacy one: fresh and
372 // restored tabs run generation 0 before their first attachment.
373 unsupported := tab.settings.unsupportedGen != 0 && tab.settings.unsupportedGen == generation
374 a.remoteTabMu.Unlock()
375 if unsupported {
376 return "", generation, nil
377 }
378 if model == "" {
379 model = resolveNewSessionModel(cfg)
380 }
381 desired := cfg.ModelRuntimeFingerprint(model)
382 if valid && applied == desired {
383 return applied, generation, nil
384 }
385 next, err := resolveModelSettingsRuntime(cfg, model)
386 if err == nil {
387 err = a.SetRemoteTabModel(tabID, next)
388 }
389 if err != nil {
390 if isRemoteModelSettingsUnsupported(err) {
391 // A legacy Serve cannot apply snapshots; admit without a revision
392 // only when the verdict belongs to the current connection.
393 a.remoteTabMu.Lock()
394 current := a.remoteTabs[tabID]
395 recorded := current == tab && current.gen == generation && current.routing.currentPath == path
396 if recorded {
397 current.settings.unsupportedGen = generation
398 }
399 a.remoteTabMu.Unlock()
400 if !recorded {
401 // A reconnect replaced the probe's target mid-flight; the
402 // replacement Serve may speak the protocol, so re-probe it
403 // instead of admitting against the retired fence.
404 continue
405 }
406 return "", generation, nil
407 }
408 a.remoteTabMu.Lock()
409 if current := a.remoteTabs[tabID]; current == tab && current.gen == generation && current.routing.currentPath == path {
410 current.settings.failure = modelSettingsIssue("apply_failed", err).Message
411 current.settings.failureRevision = desired
412 }
413 a.remoteTabMu.Unlock()
414 return "", 0, fmt.Errorf("model settings were saved but the remote session could not apply them: %w", err)
415 }
416 a.remoteTabMu.Lock()
417 acknowledged := tab.settings.revision != "" && tab.settings.generation == tab.gen && tab.settings.sessionPath == tab.routing.currentPath
418 a.remoteTabMu.Unlock()
419 if !acknowledged {
420 return "", 0, fmt.Errorf("remote session did not acknowledge the saved model settings")
421 }
422 // Read the current file again: another save may have won during the
423 // remote build. Never admit a new request with that stale completion.
424 }
425 }
426
427 func (a *App) appendRemoteModelSettingsStatus(result *ModelSettingsResult) {
428 cfg, err := config.LoadModelRuntimeSnapshot(".")
429 if err != nil {
430 return
431 } // the global read failure is already in the result
432 a.remoteTabMu.Lock()
433 defer a.remoteTabMu.Unlock()
434 for _, tab := range a.remoteTabs {
435 if tab == nil {
436 continue
437 }
438 host, ok := cfg.RemoteHost(tab.ref.HostID)
439 if !ok || !host.CredentialProxyEnabled() {
440 continue
441 }
442 model := tab.model
443 if model == "" {
444 model = resolveNewSessionModel(cfg)
445 }
446 desired := cfg.ModelRuntimeFingerprint(model)
447 // Generation 0 is an unrecorded verdict: a not-yet-attached tab has not
448 // probed any Serve and must stay pending, not claim not_required.
449 if tab.settings.unsupportedGen != 0 && tab.settings.unsupportedGen == tab.gen {
450 // Legacy targets never apply snapshots, so report them as not required
451 // instead of leaving the settings receipt pending indefinitely.
452 result.Targets = append(result.Targets, ModelSettingsTarget{TabID: tab.id, Title: tab.topicTitle, Application: "not_required", AppliedRevision: tab.settings.revision, DesiredRevision: desired})
453 continue
454 }
455 state := "applied"
456 if tab.settings.revision != desired || tab.settings.generation != tab.gen || tab.settings.sessionPath != tab.routing.currentPath {
457 state = "pending"
458 if tab.settings.failure != "" && tab.settings.failureRevision == desired {
459 state = "failed"
460 result.Application = "failed"
461 result.Issues = append(result.Issues, ModelSettingsIssue{Code: "remote_apply_failed", Message: tab.settings.failure})
462 } else if result.Application != "failed" {
463 result.Application = "pending"
464 }
465 }
466 result.Targets = append(result.Targets, ModelSettingsTarget{TabID: tab.id, Title: tab.topicTitle, Application: state, AppliedRevision: tab.settings.revision, DesiredRevision: desired})
467 }
468 }
469
470 // remoteModelApplicationState is scoped to one acknowledged session binding.
471 type remoteModelApplicationState struct {
472 revision string
473 failure string
474 failureRevision string
475 generation uint64
476 sessionPath string
477 // unsupportedGen records a generation whose Serve predates model-settings;
478 // a reconnect or replacement generation probes the protocol again.
479 unsupportedGen uint64
480 }
481
482 func applyRemoteModelSettingsSnapshot(ctx context.Context, client *http.Client, base, expectedPath, remoteRef string, bundle *config.ModelRuntimeSettings, status remoteModelSettingsStatus) (remoteModelSettingsStatus, error) {
483 var err error
484 if status.Revision != bundle.Revision || status.Model != remoteRef {
485 body := map[string]any{"version": 1, "ref": remoteRef, "settings": bundle}
486 status, err = remoteModelSettingsRequest(ctx, client, base, expectedPath, body)
487 if err != nil {
488 readCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
489 defer cancel()
490 observed, readErr := remoteModelSettingsRequest(readCtx, client, base, expectedPath, nil)
491 if readErr != nil {
492 return status, err
493 }
494 if observed.Revision != bundle.Revision || observed.Model != remoteRef {
495 return observed, err
496 }
497 status = observed
498 }
499 }
500 if status.Revision != bundle.Revision || status.Model != remoteRef {
501 return status, fmt.Errorf("remote model settings acknowledgement did not match the submitted snapshot")
502 }
503 return status, nil
504 }
505
505 lines GO