v0.33.0 653c635

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

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