| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "log/slog" |
| 5 | "time" |
| 6 | |
| 7 | "reasonix/internal/config" |
| 8 | "reasonix/internal/control" |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | func (a *App) recordModelSwitchTiming(tabID string, timing *modelSwitchTiming, started time.Time, result *error) { |
| 13 | timing.Total = time.Since(started) |
| 14 | if *result != nil { |
| 15 | timing.Outcome = "failed" |
| 16 | } else { |
| 17 | timing.Outcome = "ok" |
| 18 | } |
| 19 | slog.Debug( |
| 20 | "desktop: model switch timing", |
| 21 | "tab", tabID, |
| 22 | "outcome", timing.Outcome, |
| 23 | "total_ms", timing.Total.Milliseconds(), |
| 24 | "lock_wait_ms", timing.LockWait.Milliseconds(), |
| 25 | "prepare_ms", timing.Prepare.Milliseconds(), |
| 26 | "config_ms", timing.Config.Milliseconds(), |
| 27 | "snapshot_ms", timing.Snapshot.Milliseconds(), |
| 28 | "build_ms", timing.Build.Milliseconds(), |
| 29 | "lease_resume_ms", timing.LeaseAndResume.Milliseconds(), |
| 30 | "swap_persist_ms", timing.SwapAndPersist.Milliseconds(), |
| 31 | ) |
| 32 | if a.modelSwitchTimingHook != nil { |
| 33 | a.modelSwitchTimingHook(*timing) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func (a *App) ModelsForTab(tabID string) []ModelInfo { |
| 38 | if cur, ok := a.remoteTabCurrentModel(tabID); ok { |
| 39 | if !a.remoteTabLocalProxy(tabID) { |
| 40 | if infos, err := a.remoteServeModelsForTab(tabID, cur); err == nil { |
| 41 | return infos |
| 42 | } |
| 43 | return []ModelInfo{} |
| 44 | } |
| 45 | return a.remoteProxyModelCatalog(cur) |
| 46 | } |
| 47 | a.mu.RLock() |
| 48 | curModel := "" |
| 49 | workspaceRoot := "" |
| 50 | var ctrl control.SessionAPI |
| 51 | if tab := a.tabByIDLocked(tabID); tab != nil { |
| 52 | curModel = tab.model |
| 53 | workspaceRoot = tab.WorkspaceRoot |
| 54 | ctrl = tab.Ctrl |
| 55 | } |
| 56 | a.mu.RUnlock() |
| 57 | return a.desktopModelCatalog(curModel, workspaceRoot, ctrl) |
| 58 | } |
| 59 | |
| 60 | func (a *App) ModelsForDraft(draftID string) []ModelInfo { |
| 61 | record, op, err := a.draftStore().State(a.bootContext(), draftID) |
| 62 | if err != nil { |
| 63 | return []ModelInfo{} |
| 64 | } |
| 65 | view, err := a.draftViewForOperation(record, op) |
| 66 | if err != nil { |
| 67 | return []ModelInfo{} |
| 68 | } |
| 69 | root := record.WorkspaceRoot |
| 70 | if record.Scope != "project" { |
| 71 | root = globalWorkspaceRoot() |
| 72 | } |
| 73 | return a.desktopModelCatalog(view.Settings.Model, root, nil) |
| 74 | } |
| 75 | |
| 76 | func (a *App) remoteProxyModelCatalog(curModel string) []ModelInfo { |
| 77 | cfg, err := config.Load() |
| 78 | if err != nil { |
| 79 | return []ModelInfo{} |
| 80 | } |
| 81 | canonical := curModel |
| 82 | if current, ok := cfg.ResolveModel(curModel); ok { |
| 83 | canonical = current.Name + "/" + current.Model |
| 84 | } |
| 85 | out := []ModelInfo{} |
| 86 | for i := range cfg.Providers { |
| 87 | entry := &cfg.Providers[i] |
| 88 | if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, entry.Name) || !entry.Configured() { |
| 89 | continue |
| 90 | } |
| 91 | for _, model := range entry.ChatModelList() { |
| 92 | ref := entry.Name + "/" + model |
| 93 | out = append(out, configuredModelInfo(cfg, entry.Name, model, ref == canonical)) |
| 94 | } |
| 95 | } |
| 96 | return out |
| 97 | } |
| 98 | |
| 99 | func (a *App) desktopModelCatalog(curModel, workspaceRoot string, ctrl control.SessionAPI) []ModelInfo { |
| 100 | // A cold extension catalog fetch can block on a sidecar RPC, so read it |
| 101 | // before loading config and without holding App.mu. |
| 102 | var extensionCatalog []provider.Descriptor |
| 103 | if ctrl != nil { |
| 104 | extensionCatalog = ctrl.ProviderCatalog() |
| 105 | } |
| 106 | cfg, err := config.LoadForRoot(workspaceRoot) |
| 107 | if err != nil { |
| 108 | return []ModelInfo{} |
| 109 | } |
| 110 | if entry, ok := cfg.ResolveModel(curModel); ok { |
| 111 | curModel = entry.Name + "/" + entry.Model |
| 112 | } |
| 113 | out := []ModelInfo{} |
| 114 | for i := range cfg.Providers { |
| 115 | p := &cfg.Providers[i] |
| 116 | if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() { |
| 117 | continue |
| 118 | } |
| 119 | for _, m := range p.ChatModelList() { |
| 120 | ref := p.Name + "/" + m |
| 121 | out = append(out, configuredModelInfo(cfg, p.Name, m, ref == curModel)) |
| 122 | } |
| 123 | } |
| 124 | return mergeExtensionModelInfos(out, extensionCatalog, curModel) |
| 125 | } |
| 126 | |
| 127 | // configuredModelInfo projects the same per-model overrides and capability |
| 128 | // resolver used by settings, without guessing capabilities for unknown models. |
| 129 | func configuredModelInfo(cfg *config.Config, name, model string, current bool) ModelInfo { |
| 130 | info := ModelInfo{Ref: name + "/" + model, Provider: name, Model: model, Current: current} |
| 131 | if entry, ok := cfg.ResolveModel(info.Ref); ok { |
| 132 | info.DisplayName = entry.DisplayName |
| 133 | info.ContextWindow = entry.ContextWindow |
| 134 | capability := config.NewModelCapabilityResolver().Resolve(entry) |
| 135 | info.Vision = string(capability.State) == "supported" |
| 136 | } |
| 137 | return info |
| 138 | } |
| 139 |