v0.42.0 82578de

modelsync.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"crypto/sha256"
6	"fmt"
7	"os"
8	"path/filepath"
9	"reflect"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/bnema/zut/packages/provider"
15)
16
17var (
18	discoverOpenCodeGoFn = provider.DiscoverOpenCodeGo
19	discoverOllamaFn     = provider.DiscoverOllama
20	modelCatalogMu       sync.Mutex
21)
22
23// ModelCachePath returns the on-disk location of the merged model cache.
24func ModelCachePath() string {
25	return filepath.Join(ZutHome(), "models-cache.json")
26}
27
28// UserModelsPath returns the path to the user's models.json override.
29func UserModelsPath() string {
30	return filepath.Join(ZutHome(), "models.json")
31}
32
33// LoadCachedModels loads the cache file and applies it to the provider
34// package so FindModel / ModelsForProvider see live ids immediately.
35// Safe to call before any credentials are known.
36func LoadCachedModels() {
37	loadCachedModels(currentModelProviderScopes())
38}
39
40func synchronousOpenCodeGoAPIKey(ctx context.Context, explicitProvider, explicitAPIKey string) string {
41	providerName := effectiveCatalogProvider(explicitProvider)
42	if explicitAPIKey != "" || (providerName != "" && providerName != provider.ProviderOpenCodeGo) {
43		return explicitAPIKey
44	}
45	key, method, _, err := resolveCredentialFull(ctx, provider.ProviderOpenCodeGo, "", apiKeyCommandExecute)
46	if err != nil || method != "apikey" {
47		return ""
48	}
49	return key
50}
51
52// ResolveSDK prepares the credential-scoped catalog and resolves an SDK
53// runtime against a private model snapshot. Preparation and snapshot capture
54// are coordinated with background publication, while credential resolution
55// itself remains outside the process-wide catalog lock.
56func ResolveSDK(ctx context.Context, args Args) (Resolved, error) {
57	if ctx == nil {
58		ctx = context.Background()
59	}
60	if err := ctx.Err(); err != nil {
61		return Resolved{}, err
62	}
63	if args.APIKey == "" && effectiveCatalogProvider(args.Provider) == provider.ProviderOpenCodeGo {
64		args.APIKey = synchronousOpenCodeGoAPIKey(ctx, args.Provider, args.APIKey)
65		if err := ctx.Err(); err != nil {
66			return Resolved{}, err
67		}
68	}
69	modelCatalogMu.Lock()
70	userModels := LoadUserModels()
71	_, _ = prepareRuntimeCatalog(args.Provider, args.APIKey, args.BaseURL, userModels, args.Model)
72	// Keep an explicit empty snapshot as well: if Resolve later falls back
73	// to OpenCode Go because only that provider has credentials, it must not
74	// reread a concurrently changing global overlay.
75	args.modelCatalog = append([]provider.Model{}, provider.ModelsForProvider(provider.ProviderOpenCodeGo)...)
76	args.modelCatalogAuthoritative = provider.IsProviderCatalogAuthoritative(provider.ProviderOpenCodeGo)
77	modelCatalogMu.Unlock()
78	if err := ctx.Err(); err != nil {
79		return Resolved{}, err
80	}
81
82	resolved, err := Resolve(args, true)
83	if err == nil && resolved.Provider == provider.ProviderOpenCodeGo {
84		refreshModelsAsyncForProvider(ctx, resolved.Provider, resolved.Credential, resolved.BaseURL, provider.ProviderOpenCodeGo)
85	}
86	return resolved, err
87}
88
89func loadCachedModels(scopes map[string]string) {
90	// A process can construct SDK runtimes for different credentials in
91	// sequence. Remove the previous OpenCode Go live overlay before applying
92	// the newly scoped cache, including cache-miss and read-error paths.
93	provider.ClearLiveModelsForProvider(provider.ProviderOpenCodeGo)
94	c, err := provider.LoadCache(ModelCachePath())
95	if err != nil || c.Version != provider.ModelCacheVersion {
96		return
97	}
98	c = filterCacheByProviderScopes(c, scopes)
99	if len(c.Models) > 0 || len(c.AuthoritativeProviders) > 0 {
100		provider.SetLiveModelsForProviders(c.Models, c.AuthoritativeProviders)
101	}
102}
103
104// currentModelProviderScopes returns identifiers that make a provider's
105// discovered catalog account-specific. The cache remains provider-neutral:
106// each provider supplies an opaque scope value when it needs one.
107func currentModelProviderScopes() map[string]string {
108	return currentModelProviderScopesForBaseURL("")
109}
110
111func currentModelProviderScopesForBaseURL(openCodeGoBaseURL string) map[string]string {
112	scopes := make(map[string]string)
113	if token := loadOAuthToken("openai"); token != nil && token.AccountID != "" {
114		scopes["openai-codex"] = token.AccountID
115	}
116	if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOpenCodeGo, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
117		scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(key, openCodeGoBaseURL)
118	}
119	if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOllama, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
120		scopes[provider.ProviderOllama] = credentialScopeForEndpoint(key, provider.OllamaCloudBaseURL)
121	}
122	return scopes
123}
124
125// credentialScope lets a cache follow a credential without storing the
126// credential itself. API keys are high-entropy values, and the cache is also
127// protected with restrictive file permissions.
128func credentialScope(credential string) string {
129	return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(credential)))
130}
131
132func credentialScopeForEndpoint(credential, baseURL string) string {
133	endpoint := strings.TrimRight(strings.TrimSpace(baseURL), "/")
134	if endpoint == "" {
135		endpoint = provider.OpenCodeGoDefaultBaseURL
136	}
137	return credentialScope(credential + "\x00" + endpoint)
138}
139
140func modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL string) map[string]string {
141	openCodeGoBaseURL := ""
142	if explicitProvider == provider.ProviderOpenCodeGo {
143		openCodeGoBaseURL = explicitBaseURL
144	}
145	scopes := currentModelProviderScopesForBaseURL(openCodeGoBaseURL)
146	if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
147		scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(explicitAPIKey, firstNonEmpty(explicitBaseURL, provider.OpenCodeGoDefaultBaseURL))
148	}
149	if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
150		scopes[provider.ProviderOllama] = credentialScopeForEndpoint(explicitAPIKey, firstNonEmpty(explicitBaseURL, provider.OllamaCloudBaseURL))
151	}
152	return scopes
153}
154
155// filterCacheByProviderScopes removes cached entries whose provider scope no
156// longer matches the active credential. It also removes the authoritative
157// marker so that a failed refresh falls back to the baked-in catalog instead
158// of treating another account's empty catalog as authoritative.
159func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string) provider.ModelCache {
160	return filterCacheByProviderScopesExcept(c, scopes, "")
161}
162
163func filterCacheByProviderScopesExcept(c provider.ModelCache, scopes map[string]string, keepProvider string) provider.ModelCache {
164	out := provider.ModelCache{
165		Version:                c.Version,
166		FetchedAt:              c.FetchedAt,
167		Models:                 append([]provider.Model(nil), c.Models...),
168		AuthoritativeProviders: append([]string(nil), c.AuthoritativeProviders...),
169		ProviderScopes:         make(map[string]string, len(c.ProviderScopes)),
170	}
171	for name, scope := range c.ProviderScopes {
172		out.ProviderScopes[name] = scope
173	}
174
175	scopedProviders := map[string]struct{}{
176		provider.ProviderOpenAICodex: {},
177		provider.ProviderOpenCodeGo:  {},
178	}
179	for name := range c.ProviderScopes {
180		scopedProviders[name] = struct{}{}
181	}
182	for name := range scopes {
183		scopedProviders[name] = struct{}{}
184	}
185	for name := range scopedProviders {
186		if name == keepProvider {
187			continue
188		}
189		if cachedScope := c.ProviderScopes[name]; cachedScope != "" && cachedScope == scopes[name] {
190			continue
191		}
192		out.Models = filterModelsByProvider(out.Models, name)
193		out.AuthoritativeProviders = filterProviderNames(out.AuthoritativeProviders, name)
194		delete(out.ProviderScopes, name)
195	}
196	return out
197}
198
199func filterModelsByProvider(models []provider.Model, name string) []provider.Model {
200	out := make([]provider.Model, 0, len(models))
201	for _, model := range models {
202		if model.Provider != name {
203			out = append(out, model)
204		}
205	}
206	return out
207}
208
209func filterProviderNames(names []string, target string) []string {
210	out := make([]string, 0, len(names))
211	for _, name := range names {
212		if name != target {
213			out = append(out, name)
214		}
215	}
216	return out
217}
218
219func providerScopesEqual(a, b map[string]string) bool {
220	if len(a) != len(b) {
221		return false
222	}
223	for name, scope := range a {
224		if b[name] != scope {
225			return false
226		}
227	}
228	return true
229}
230
231// LoadUserModels reads $ZUT_HOME/models.json and merges any user-defined
232// models into the active catalog. User models take highest precedence.
233// Any validation issues (bad provider id, empty model id, malformed
234// JSON, negative widths) are surfaced as one warning per line on stderr;
235// the well-formed entries from the rest of the file are still loaded.
236func LoadUserModels() []provider.Model {
237	models, warnings := provider.LoadUserModelsWithWarnings(UserModelsPath())
238	for _, w := range warnings {
239		fmt.Fprintln(os.Stderr, "zut:", w)
240	}
241	provider.SetUserModels(models)
242	return models
243}
244
245// isGatewayProvider returns true for providers whose OpenAI-compatible
246// endpoint can accept routed model IDs that are not present in zut's local
247// catalog. Vercel AI Gateway is intentionally not listed here: zut currently
248// talks to it through the Anthropic-compatible client, which still requires
249// catalog metadata for request shaping.
250func isGatewayProvider(prov string) bool {
251	switch prov {
252	case "openrouter", "cloudflare-ai-gateway":
253		return true
254	default:
255		return false
256	}
257}
258
259// isGatewayRoutedModelID reports whether a model looks like the routed IDs
260// used by gateway providers, for example "deepseek/deepseek-v4-flash".
261// Non-routed typos like "deepseek-v4-flashh" should still be repaired to a
262// known default instead of being silently accepted.
263func isGatewayRoutedModelID(model string) bool {
264	return strings.Contains(strings.TrimSpace(model), "/")
265}
266
267// ValidateAndRepairConfig checks the persisted config.json's
268// (Provider, Model) pair against the active catalog and repairs any
269// mismatch in-place (and on disk) before any UI renders. Three failure
270// modes are handled:
271//
272//   - cfg.Provider is empty or unknown -> reset to "anthropic".
273//   - cfg.Model is empty -> set to the provider's default.
274//   - cfg.Model belongs to a different provider than cfg.Provider
275//     (e.g. provider=anthropic + model=kimi-for-coding from a stale
276//     half-applied switch) -> reset model to the provider's default.
277//
278// Gateway providers are exempt from the cross-provider model check for routed
279// model IDs because those IDs can be valid even when absent from zut's catalog.
280// OpenCode Go is also exempt from the unknown-model repair because its
281// provider catalog is authoritative but live-only.
282//
283// Silent on success; one stderr line per repair. Errors loading or
284// saving the file are non-fatal — the caller continues with defaults.
285func ValidateAndRepairConfig() {
286	cfg, err := LoadConfig()
287	if err != nil {
288		fmt.Fprintf(os.Stderr, "zut: config.json: %v (using defaults)\n", err)
289		return
290	}
291	changed := false
292
293	if cfg.Provider != "" && !isKnownProvider(cfg.Provider) {
294		fmt.Fprintf(os.Stderr, "zut: config.json: unknown provider %q reset to \"anthropic\"\n", cfg.Provider)
295		cfg.Provider = "anthropic"
296		cfg.Model = ""
297		changed = true
298	}
299
300	modelID := strings.TrimSpace(cfg.Model)
301	if cfg.Provider != "" && modelID != "" {
302		if _, err := provider.FindModel(cfg.Provider, modelID); err != nil {
303			// OpenCode Go is authoritative at runtime and intentionally has no
304			// baked-in entries, so an uncached id must survive startup repair.
305			if provider.AcceptsUnlistedModels(cfg.Provider) {
306				// The live refresh will validate the id when it is available.
307			} else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) {
308				// Provider is a router and the id is route-qualified; preserve it.
309			} else if m, err := provider.FindModel("", modelID); err == nil {
310				fix := defaultModelForProvider(cfg.Provider)
311				fmt.Fprintf(os.Stderr,
312					"zut: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
313					modelID, m.Provider, cfg.Provider, fix)
314				cfg.Model = fix
315				changed = true
316			} else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID && !provider.AcceptsUnlistedModels(cfg.Provider) {
317				// Model id not in any catalog. Reset to provider's default.
318				fix := defaultModelForProvider(cfg.Provider)
319				fmt.Fprintf(os.Stderr,
320					"zut: config.json: model %q not found in the active catalog; switched to %q\n",
321					modelID, fix)
322				cfg.Model = fix
323				changed = true
324			}
325		}
326	}
327
328	if changed {
329		if err := SaveConfig(cfg); err != nil {
330			fmt.Fprintf(os.Stderr, "zut: config.json: failed to persist repair: %v\n", err)
331		}
332	}
333}
334
335// RefreshModelsAsync kicks a background discovery for every provider
336// we have credentials for. Refreshed results are merged into the
337// active catalog and persisted to the on-disk cache.
338//
339// Silent on error: discovery is a nice-to-have. Callers can still use
340// the baked-in catalog if this fails.
341func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL ...string) {
342	baseURL := ""
343	if len(explicitBaseURL) > 0 {
344		baseURL = explicitBaseURL[0]
345	}
346	go refreshModels(explicitProvider, explicitAPIKey, baseURL, "")
347	go func() {
348		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
349		defer cancel()
350		_ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
351	}()
352}
353
354func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
355	if ctx == nil {
356		ctx = context.Background()
357	}
358	if ctx.Err() != nil {
359		return
360	}
361	go refreshModelsWithContext(ctx, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, apiKeyCommandSkip)
362}
363
364// RefreshLlamaCPPModels adds the router's currently loaded models to the
365// active catalog. Unloaded models remain in the management UI and cannot be
366// selected for inference until they are loaded.
367func RefreshLlamaCPPModels(ctx context.Context) error {
368	return refreshLlamaCPPModels(ctx, apiKeyCommandExecute)
369}
370
371func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) error {
372	baseURL, apiKey, err := resolveLlamaCPPConfig(ctx, commandMode)
373	if err != nil || baseURL == "" {
374		return err
375	}
376	client, err := provider.NewLlamaCPPClient(baseURL, apiKey)
377	if err != nil {
378		return err
379	}
380	models, err := client.List(ctx, false)
381	if err != nil {
382		return err
383	}
384	provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))
385	return nil
386}
387
388// needsOpenCodeGoRefresh handles a credential added after a fresh cache was
389// written. Without this check, a cache created before the first OpenCode Go
390// login would hide the provider until the 24-hour TTL expired.
391func needsOpenCodeGoRefresh(c provider.ModelCache) bool {
392	if !CredentialAvailable(provider.ProviderOpenCodeGo) {
393		return false
394	}
395	for _, model := range c.Models {
396		if model.Provider == provider.ProviderOpenCodeGo {
397			return false
398		}
399	}
400	for _, name := range c.AuthoritativeProviders {
401		if name == provider.ProviderOpenCodeGo {
402			return false
403		}
404	}
405	return true
406}
407
408func cacheSnapshotsEqual(a provider.ModelCache, aErr error, b provider.ModelCache, bErr error) bool {
409	if (aErr != nil) != (bErr != nil) {
410		return false
411	}
412	if aErr != nil {
413		return true
414	}
415	return reflect.DeepEqual(a, b)
416}
417
418func eligibleProviderDiscoveryIncomplete(eligible map[string]struct{}, discovered map[string][]provider.Model) bool {
419	for name := range eligible {
420		if _, ok := discovered[name]; !ok {
421			return true
422		}
423	}
424	return false
425}
426
427func refreshModels(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
428	refreshModelsWithMode(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, apiKeyCommandSkip)
429}
430
431func refreshModelsWithMode(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string, commandMode apiKeyCommandMode) {
432	refreshModelsWithContext(context.Background(), explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, commandMode)
433}
434
435func resolveCredentialForCatalog(ctx context.Context, providerName string, mode apiKeyCommandMode) (cred, method string, err error) {
436	cred, method, _, err = resolveCredentialFull(ctx, providerName, "", mode)
437	return cred, method, err
438}
439
440func refreshModelsWithContext(parent context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string, commandMode apiKeyCommandMode) {
441	if parent == nil {
442		parent = context.Background()
443	}
444	explicitProvider = effectiveCatalogProvider(explicitProvider)
445	cached, cachedErr := provider.LoadCache(ModelCachePath())
446	ctx, cancel := context.WithTimeout(parent, 20*time.Second)
447	defer cancel()
448
449	openCodeGoCred, openCodeGoMethod := "", ""
450	if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
451		openCodeGoCred, openCodeGoMethod = explicitAPIKey, "apikey"
452	} else {
453		openCodeGoCred, openCodeGoMethod, _, _ = resolveCredentialFull(ctx, provider.ProviderOpenCodeGo, "", commandMode)
454	}
455	currentScopes := modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL)
456	openCodeGoBaseURL := ""
457	if explicitProvider == provider.ProviderOpenCodeGo {
458		openCodeGoBaseURL = explicitBaseURL
459	}
460	if openCodeGoMethod == "apikey" && openCodeGoCred != "" {
461		currentScopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(openCodeGoCred, openCodeGoBaseURL)
462	}
463	if cached.IsFresh() &&
464		cached.Version == provider.ModelCacheVersion &&
465		providerScopesEqual(cached.ProviderScopes, currentScopes) &&
466		((onlyProvider != "" && onlyProvider != provider.ProviderOpenCodeGo) || !needsOpenCodeGoRefresh(cached)) {
467		return
468	}
469
470	discovered := make(map[string][]provider.Model)
471	authoritativeDiscovered := make(map[string]bool)
472	discoveredScopes := make(map[string]string)
473	eligibleProviders := make(map[string]struct{}, len(currentScopes))
474	for name := range currentScopes {
475		eligibleProviders[name] = struct{}{}
476	}
477	markEligible := func(name string) {
478		eligibleProviders[name] = struct{}{}
479	}
480	recordDiscovery := func(name string, models []provider.Model, authoritative bool, scope string) {
481		discovered[name] = append([]provider.Model(nil), models...)
482		if authoritative {
483			authoritativeDiscovered[name] = true
484		}
485		if scope != "" {
486			discoveredScopes[name] = scope
487		}
488	}
489	if onlyProvider == "" || onlyProvider == provider.ProviderAnthropic {
490		if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderAnthropic, commandMode); err == nil && method == "apikey" {
491			markEligible(provider.ProviderAnthropic)
492			// /v1/models on Anthropic is API-key only; OAuth tokens can
493			// also list models via the bearer header, but we skip OAuth
494			// here to avoid surprise rate-limit hits on subscription keys.
495			if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
496				recordDiscovery(provider.ProviderAnthropic, live, false, "")
497			}
498		}
499	}
500	if onlyProvider == "" || onlyProvider == provider.ProviderOpenAI {
501		if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOpenAI, commandMode); err == nil && method == "apikey" {
502			markEligible(provider.ProviderOpenAI)
503			if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
504				recordDiscovery(provider.ProviderOpenAI, live, false, "")
505			}
506		}
507	}
508	if onlyProvider == "" {
509		var ollamaKey string
510		if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
511			ollamaKey = explicitAPIKey
512		} else if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOllama, commandMode); err == nil && method == "apikey" {
513			ollamaKey = cred
514		}
515		if ollamaKey != "" {
516			markEligible(provider.ProviderOllama)
517			baseURL := provider.OllamaCloudBaseURL
518			if explicitProvider == provider.ProviderOllama && explicitBaseURL != "" {
519				baseURL = explicitBaseURL
520			}
521			if live, err := discoverOllamaFn(ctx, ollamaKey, baseURL); err == nil {
522				scope := credentialScopeForEndpoint(ollamaKey, baseURL)
523				recordDiscovery(provider.ProviderOllama, live, false, scope)
524			}
525		}
526	}
527	if onlyProvider == "" || onlyProvider == provider.ProviderOpenAICodex {
528		if cred, method, accountID, err := resolveCredentialFull(ctx, provider.ProviderOpenAICodex, "", commandMode); err == nil && method == "oauth" {
529			markEligible(provider.ProviderOpenAICodex)
530			if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
531				recordDiscovery(provider.ProviderOpenAICodex, live, accountID != "", accountID)
532			}
533		}
534	}
535	if onlyProvider == "" || onlyProvider == provider.ProviderOpenCodeGo {
536		if openCodeGoMethod == "apikey" {
537			markEligible(provider.ProviderOpenCodeGo)
538			if live, err := discoverOpenCodeGoFn(ctx, openCodeGoCred, openCodeGoBaseURL); err == nil {
539				recordDiscovery(provider.ProviderOpenCodeGo, live, true, credentialScopeForEndpoint(openCodeGoCred, openCodeGoBaseURL))
540			}
541		}
542	}
543	if onlyProvider == "" || onlyProvider == provider.ProviderKimi {
544		if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderKimi, commandMode); err == nil && method == "apikey" {
545			markEligible(provider.ProviderKimi)
546			if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
547				for i := range live {
548					live[i].Provider = provider.ProviderKimi
549					live[i].Source = "live"
550				}
551				recordDiscovery(provider.ProviderKimi, live, false, "")
552			}
553		}
554	}
555	if onlyProvider == "" || onlyProvider == provider.ProviderGoogle {
556		if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderGoogle, commandMode); err == nil && method == "apikey" {
557			markEligible(provider.ProviderGoogle)
558			if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
559				recordDiscovery(provider.ProviderGoogle, live, false, "")
560			}
561		}
562	}
563	if onlyProvider == "" || onlyProvider == provider.ProviderOpenRouter {
564		if _, _, err := resolveCredentialForCatalog(ctx, provider.ProviderOpenRouter, commandMode); err == nil {
565			markEligible(provider.ProviderOpenRouter)
566			// /models is public; gate on a credential so the picker only
567			// fills with OpenRouter's hundreds of routes for users who use it.
568			if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
569				recordDiscovery(provider.ProviderOpenRouter, live, false, "")
570			}
571		}
572	}
573
574	if len(discovered) == 0 || ctx.Err() != nil {
575		return
576	}
577
578	modelCatalogMu.Lock()
579	defer modelCatalogMu.Unlock()
580	if ctx.Err() != nil {
581		return
582	}
583	latestSnapshot, latestErr := provider.LoadCache(ModelCachePath())
584	latest := latestSnapshot
585	if latest.Version != provider.ModelCacheVersion {
586		// Routing metadata from an older cache format is not safe to merge
587		// into a newly discovered catalog. Treat it as empty, but compare the
588		// raw snapshot below so a concurrent refresh is still detected.
589		latest = provider.ModelCache{}
590	}
591	preserveFullRefreshFreshness := onlyProvider == "" && eligibleProviderDiscoveryIncomplete(eligibleProviders, discovered)
592	var all []provider.Model
593	var authoritativeProviders []string
594	var providerScopes map[string]string
595	_, latestHasOpenCodeGo := latestSnapshot.ProviderScopes[provider.ProviderOpenCodeGo]
596	keepOpenCodeGo := !cacheSnapshotsEqual(cached, cachedErr, latestSnapshot, latestErr) && latestHasOpenCodeGo
597	if keepOpenCodeGo {
598		// Another runtime replaced the cache while this discovery was in
599		// flight and owns newer OpenCode Go scope data. Keep its scope and
600		// still publish any unrelated providers that this refresh
601		// discovered. When the newer snapshot carries no OpenCode Go scope,
602		// this refresh's own discovery is the only such data and is
603		// published below instead of being discarded.
604		delete(discovered, provider.ProviderOpenCodeGo)
605		delete(authoritativeDiscovered, provider.ProviderOpenCodeGo)
606		delete(discoveredScopes, provider.ProviderOpenCodeGo)
607	}
608	if len(discovered) == 0 {
609		return
610	}
611	if keepOpenCodeGo {
612		latest = filterCacheByProviderScopesExcept(latest, currentScopes, provider.ProviderOpenCodeGo)
613	} else {
614		latest = filterCacheByProviderScopes(latest, currentScopes)
615	}
616	all = append([]provider.Model(nil), latest.Models...)
617	authoritativeProviders = append([]string(nil), latest.AuthoritativeProviders...)
618	providerScopes = make(map[string]string, len(latest.ProviderScopes))
619	for name, scope := range latest.ProviderScopes {
620		providerScopes[name] = scope
621	}
622	for name, live := range discovered {
623		all = filterModelsByProvider(all, name)
624		authoritativeProviders = filterProviderNames(authoritativeProviders, name)
625		delete(providerScopes, name)
626		all = append(all, live...)
627		if authoritativeDiscovered[name] {
628			authoritativeProviders = append(authoritativeProviders, name)
629		}
630		if scope := discoveredScopes[name]; scope != "" {
631			providerScopes[name] = scope
632		}
633	}
634	if len(all) == 0 && len(authoritativeProviders) == 0 {
635		return
636	}
637	if ctx.Err() != nil {
638		return
639	}
640	provider.SetLiveModelsForProviders(all, authoritativeProviders)
641	fetchedAt := time.Now().UTC()
642	if onlyProvider != "" {
643		if latest.Version == provider.ModelCacheVersion {
644			// A partial refresh must not make an otherwise stale full
645			// catalog look fresh to the next CLI refresh.
646			fetchedAt = latest.FetchedAt
647		} else {
648			fetchedAt = time.Time{}
649		}
650	} else if preserveFullRefreshFreshness {
651		// A failed eligible discovery must not make stale metadata appear
652		// fresh merely because another provider refreshed successfully.
653		fetchedAt = latest.FetchedAt
654	}
655	if ctx.Err() != nil {
656		return
657	}
658	_ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
659		Version:                provider.ModelCacheVersion,
660		FetchedAt:              fetchedAt,
661		Models:                 all,
662		AuthoritativeProviders: authoritativeProviders,
663		ProviderScopes:         providerScopes,
664	})
665}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close