fix(provider): include full catalog in model picker, gate by credentials
Active() captured Catalog into a package var initializer, which runs before the init() functions in catalog_builtin.go/extra_models.go append the extended catalog. The picker therefore only ever saw the curated seed list, dropping openrouter and every other extra provider. Defer the Catalog read to call time so Active() reflects the fully-assembled list. Also make the model dialog filter strictly by logged-in providers: an empty credential set now yields an empty picker (with a /login hint) instead of dumping the entire ~900-model catalog.
- commit
- edbbcc1086ea497e96a97b13a6213c40cade145b
- author
- patriceckhart · · 2026-05-29 09:26 UTC
- parent
- 5470345
- signature
- unsigned
2 files · +39 -19
M packages/agent/modes/model_dialog.go +19 -14
| @@ -48,21 +48,22 @@ func newModelDialog() *modelDialog { | |||
| 48 | 48 | // it can be pre-selected. | |
| 49 | 49 | func (d *modelDialog) Open(current string, loggedInProviders []string) { | |
| 50 | 50 | d.active = true | |
| 51 | - | all := provider.Active() | |
| 52 | - | if len(loggedInProviders) > 0 { | |
| 53 | - | provSet := map[string]bool{} | |
| 54 | - | for _, p := range loggedInProviders { | |
| 55 | - | provSet[p] = true | |
| 56 | - | } | |
| 57 | - | var filtered []provider.Model | |
| 58 | - | for _, m := range all { | |
| 59 | - | if provSet[m.Provider] { | |
| 60 | - | filtered = append(filtered, m) | |
| 61 | - | } | |
| 51 | + | // Only surface models the user can actually reach: a provider is | |
| 52 | + | // shown only when it has a resolvable credential (api key, oauth | |
| 53 | + | // subscription, or the always-available ollama). An empty | |
| 54 | + | // loggedInProviders list therefore yields an empty picker rather | |
| 55 | + | // than dumping the entire ~900-model catalog. | |
| 56 | + | provSet := map[string]bool{} | |
| 57 | + | for _, p := range loggedInProviders { | |
| 58 | + | provSet[p] = true | |
| 59 | + | } | |
| 60 | + | var filtered []provider.Model | |
| 61 | + | for _, m := range provider.Active() { | |
| 62 | + | if provSet[m.Provider] { | |
| 63 | + | filtered = append(filtered, m) | |
| 62 | 64 | } | |
| 63 | - | all = filtered | |
| 64 | 65 | } | |
| 65 | - | d.all = sortedModels(all) | |
| 66 | + | d.all = sortedModels(filtered) | |
| 66 | 67 | d.current = current | |
| 67 | 68 | d.query = "" | |
| 68 | 69 | d.provW, d.idW = columnWidths(d.all) | |
| @@ -148,7 +149,11 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 148 | 149 | lines = append(lines, th.FG256(th.Muted, hint)) | |
| 149 | 150 | ||
| 150 | 151 | if len(d.view) == 0 { | |
| 151 | - | lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query))) | |
| 152 | + | msg := " no models match " + fmt.Sprintf("%q", d.query) | |
| 153 | + | if len(d.all) == 0 { | |
| 154 | + | msg = " no credentials found - run /login to add an api key or subscription" | |
| 155 | + | } | |
| 156 | + | lines = append(lines, th.FG256(th.Muted, msg)) | |
| 152 | 157 | lines = append(lines, frameRule(th, width)) | |
| 153 | 158 | return lines | |
| 154 | 159 | } | |
| @@ -48,21 +48,22 @@ func newModelDialog() *modelDialog { | |||||
| 48 | // it can be pre-selected. | 48 | // it can be pre-selected. | ||
| 49 | func (d *modelDialog) Open(current string, loggedInProviders []string) { | 49 | func (d *modelDialog) Open(current string, loggedInProviders []string) { | ||
| 50 | d.active = true | 50 | d.active = true | ||
| 51 | - | all := provider.Active() | 51 | + | // Only surface models the user can actually reach: a provider is |
| 52 | - | if len(loggedInProviders) > 0 { | 52 | + | // shown only when it has a resolvable credential (api key, oauth |
| 53 | - | provSet := map[string]bool{} | 53 | + | // subscription, or the always-available ollama). An empty |
| 54 | - | for _, p := range loggedInProviders { | 54 | + | // loggedInProviders list therefore yields an empty picker rather |
| 55 | - | provSet[p] = true | 55 | + | // than dumping the entire ~900-model catalog. |
| 56 | - | } | 56 | + | provSet := map[string]bool{} |
| 57 | - | var filtered []provider.Model | 57 | + | for _, p := range loggedInProviders { |
| 58 | - | for _, m := range all { | 58 | + | provSet[p] = true |
| 59 | - | if provSet[m.Provider] { | 59 | + | } |
| 60 | - | filtered = append(filtered, m) | 60 | + | var filtered []provider.Model |
| 61 | - | } | 61 | + | for _, m := range provider.Active() { |
| 62 | + | if provSet[m.Provider] { | |||
| 63 | + | filtered = append(filtered, m) | |||
| 62 | } | 64 | } | ||
| 63 | - | all = filtered | |||
| 64 | } | 65 | } | ||
| 65 | - | d.all = sortedModels(all) | 66 | + | d.all = sortedModels(filtered) |
| 66 | d.current = current | 67 | d.current = current | ||
| 67 | d.query = "" | 68 | d.query = "" | ||
| 68 | d.provW, d.idW = columnWidths(d.all) | 69 | d.provW, d.idW = columnWidths(d.all) | ||
| @@ -148,7 +149,11 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||||
| 148 | lines = append(lines, th.FG256(th.Muted, hint)) | 149 | lines = append(lines, th.FG256(th.Muted, hint)) | ||
| 149 | 150 | ||||
| 150 | if len(d.view) == 0 { | 151 | if len(d.view) == 0 { | ||
| 151 | - | lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query))) | 152 | + | msg := " no models match " + fmt.Sprintf("%q", d.query) |
| 153 | + | if len(d.all) == 0 { | |||
| 154 | + | msg = " no credentials found - run /login to add an api key or subscription" | |||
| 155 | + | } | |||
| 156 | + | lines = append(lines, th.FG256(th.Muted, msg)) | |||
| 152 | lines = append(lines, frameRule(th, width)) | 157 | lines = append(lines, frameRule(th, width)) | ||
| 153 | return lines | 158 | return lines | ||
| 154 | } | 159 | } | ||
M packages/provider/models.go +20 -5
| @@ -332,8 +332,9 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5 | |||
| 332 | 332 | // models loaded via SetLiveModels. | |
| 333 | 333 | ||
| 334 | 334 | var ( | |
| 335 | - | activeMu sync.RWMutex | |
| 336 | - | active []Model = Catalog // default: just the static catalog | |
| 335 | + | activeMu sync.RWMutex | |
| 336 | + | active []Model // live overlay merged in via SetLiveModels; nil = none yet | |
| 337 | + | activeSet bool // true once SetLiveModels has run (even with empty live) | |
| 337 | 338 | ) | |
| 338 | 339 | ||
| 339 | 340 | // SetLiveModels replaces the "live" overlay used by the active catalog. | |
| @@ -342,19 +343,33 @@ var ( | |||
| 342 | 343 | func SetLiveModels(live []Model) { | |
| 343 | 344 | activeMu.Lock() | |
| 344 | 345 | defer activeMu.Unlock() | |
| 346 | + | activeSet = true | |
| 345 | 347 | if len(live) == 0 { | |
| 346 | - | active = Catalog | |
| 348 | + | active = nil | |
| 347 | 349 | return | |
| 348 | 350 | } | |
| 349 | 351 | active = MergeCatalog(live) | |
| 350 | 352 | } | |
| 351 | 353 | ||
| 352 | 354 | // Active returns the current merged catalog. | |
| 355 | + | // | |
| 356 | + | // When no live overlay has been set it returns the fully-assembled | |
| 357 | + | // static Catalog. Reading Catalog here (rather than capturing it into | |
| 358 | + | // a package-level var initializer) is load-bearing: the extended | |
| 359 | + | // catalog in catalog_builtin.go / extra_models.go is appended from | |
| 360 | + | // init() functions, which run AFTER package-level var initializers. | |
| 361 | + | // Snapshotting Catalog at var-init time would freeze the picker to the | |
| 362 | + | // curated seed list and drop every extra provider (openrouter, groq, | |
| 363 | + | // xai, ...). Deferring the read to call time avoids that ordering trap. | |
| 353 | 364 | func Active() []Model { | |
| 354 | 365 | activeMu.RLock() | |
| 355 | 366 | defer activeMu.RUnlock() | |
| 356 | - | out := make([]Model, len(active)) | |
| 357 | - | copy(out, active) | |
| 367 | + | src := active | |
| 368 | + | if !activeSet || src == nil { | |
| 369 | + | src = Catalog | |
| 370 | + | } | |
| 371 | + | out := make([]Model, len(src)) | |
| 372 | + | copy(out, src) | |
| 358 | 373 | return out | |
| 359 | 374 | } | |
| 360 | 375 | ||
| @@ -332,8 +332,9 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5 | |||||
| 332 | // models loaded via SetLiveModels. | 332 | // models loaded via SetLiveModels. | ||
| 333 | 333 | ||||
| 334 | var ( | 334 | var ( | ||
| 335 | - | activeMu sync.RWMutex | 335 | + | activeMu sync.RWMutex |
| 336 | - | active []Model = Catalog // default: just the static catalog | 336 | + | active []Model // live overlay merged in via SetLiveModels; nil = none yet |
| 337 | + | activeSet bool // true once SetLiveModels has run (even with empty live) | |||
| 337 | ) | 338 | ) | ||
| 338 | 339 | ||||
| 339 | // SetLiveModels replaces the "live" overlay used by the active catalog. | 340 | // SetLiveModels replaces the "live" overlay used by the active catalog. | ||
| @@ -342,19 +343,33 @@ var ( | |||||
| 342 | func SetLiveModels(live []Model) { | 343 | func SetLiveModels(live []Model) { | ||
| 343 | activeMu.Lock() | 344 | activeMu.Lock() | ||
| 344 | defer activeMu.Unlock() | 345 | defer activeMu.Unlock() | ||
| 346 | + | activeSet = true | |||
| 345 | if len(live) == 0 { | 347 | if len(live) == 0 { | ||
| 346 | - | active = Catalog | 348 | + | active = nil |
| 347 | return | 349 | return | ||
| 348 | } | 350 | } | ||
| 349 | active = MergeCatalog(live) | 351 | active = MergeCatalog(live) | ||
| 350 | } | 352 | } | ||
| 351 | 353 | ||||
| 352 | // Active returns the current merged catalog. | 354 | // Active returns the current merged catalog. | ||
| 355 | + | // | |||
| 356 | + | // When no live overlay has been set it returns the fully-assembled | |||
| 357 | + | // static Catalog. Reading Catalog here (rather than capturing it into | |||
| 358 | + | // a package-level var initializer) is load-bearing: the extended | |||
| 359 | + | // catalog in catalog_builtin.go / extra_models.go is appended from | |||
| 360 | + | // init() functions, which run AFTER package-level var initializers. | |||
| 361 | + | // Snapshotting Catalog at var-init time would freeze the picker to the | |||
| 362 | + | // curated seed list and drop every extra provider (openrouter, groq, | |||
| 363 | + | // xai, ...). Deferring the read to call time avoids that ordering trap. | |||
| 353 | func Active() []Model { | 364 | func Active() []Model { | ||
| 354 | activeMu.RLock() | 365 | activeMu.RLock() | ||
| 355 | defer activeMu.RUnlock() | 366 | defer activeMu.RUnlock() | ||
| 356 | - | out := make([]Model, len(active)) | 367 | + | src := active |
| 357 | - | copy(out, active) | 368 | + | if !activeSet || src == nil { |
| 369 | + | src = Catalog | |||
| 370 | + | } | |||
| 371 | + | out := make([]Model, len(src)) | |||
| 372 | + | copy(out, src) | |||
| 358 | return out | 373 | return out | ||
| 359 | } | 374 | } | ||
| 360 | 375 | ||||