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}