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 isDiscoverableCustomProvider(cfg.Provider) {
308 // Discovery is transient and has not run yet at repair time; keep
309 // the selection even if another provider has the same model id.
310 } else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) {
311 // Provider is a router and the id is route-qualified; preserve it.
312 } else if m, err := provider.FindModel("", modelID); err == nil {
313 fix := defaultModelForProvider(cfg.Provider)
314 fmt.Fprintf(os.Stderr,
315 "zut: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
316 modelID, m.Provider, cfg.Provider, fix)
317 cfg.Model = fix
318 changed = true
319 } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID && !provider.AcceptsUnlistedModels(cfg.Provider) {
320 // Model id not in any catalog. Reset to provider's default.
321 fix := defaultModelForProvider(cfg.Provider)
322 fmt.Fprintf(os.Stderr,
323 "zut: config.json: model %q not found in the active catalog; switched to %q\n",
324 modelID, fix)
325 cfg.Model = fix
326 changed = true
327 }
328 }
329 }
330
331 if changed {
332 if err := SaveConfig(cfg); err != nil {
333 fmt.Fprintf(os.Stderr, "zut: config.json: failed to persist repair: %v\n", err)
334 }
335 }
336}
337
338// RefreshModelsAsync kicks a background discovery for every provider
339// we have credentials for. Refreshed results are merged into the
340// active catalog and persisted to the on-disk cache.
341//
342// Silent on error: discovery is a nice-to-have. Callers can still use
343// the baked-in catalog if this fails.
344func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL ...string) {
345 baseURL := ""
346 if len(explicitBaseURL) > 0 {
347 baseURL = explicitBaseURL[0]
348 }
349 go refreshModels(explicitProvider, explicitAPIKey, baseURL, "")
350 go refreshCopilotModelAvailability()
351 go refreshCustomProvidersBackground()
352 go func() {
353 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
354 defer cancel()
355 _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
356 }()
357}
358
359// refreshCopilotModelAvailability filters the Copilot catalog to the models the
360// authenticated account can select. Availability is account-specific, so it
361// lives only in memory and never in the shared disk cache. It never runs an
362// api-key command. Without a credential the restriction is cleared; a failed
363// refresh keeps the previous snapshot (or the static catalog on first load).
364func refreshCopilotModelAvailability() {
365 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
366 defer cancel()
367 cred, _, err := resolveCredentialForBackground(ctx, provider.ProviderGitHubCopilot)
368 if err != nil || cred == "" {
369 provider.SetModelAvailability(provider.ProviderGitHubCopilot, nil)
370 return
371 }
372 ids, err := provider.DiscoverCopilotAvailableModels(ctx, cred)
373 if err != nil {
374 return
375 }
376 provider.SetModelAvailability(provider.ProviderGitHubCopilot, ids)
377}
378
379func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
380 if ctx == nil {
381 ctx = context.Background()
382 }
383 if ctx.Err() != nil {
384 return
385 }
386 go refreshModelsWithContext(ctx, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, apiKeyCommandSkip)
387}
388
389// RefreshLlamaCPPModels adds the router's currently loaded models to the
390// active catalog. Unloaded models remain in the management UI and cannot be
391// selected for inference until they are loaded.
392func RefreshLlamaCPPModels(ctx context.Context) error {
393 return refreshLlamaCPPModels(ctx, apiKeyCommandExecute)
394}
395
396func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) error {
397 baseURL, apiKey, err := resolveLlamaCPPConfig(ctx, commandMode)
398 if err != nil || baseURL == "" {
399 return err
400 }
401 client, err := provider.NewLlamaCPPClient(baseURL, apiKey)
402 if err != nil {
403 return err
404 }
405 models, err := client.List(ctx, false)
406 if err != nil {
407 return err
408 }
409 provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
410 return nil
411}
412
413// needsOpenCodeGoRefresh handles a credential added after a fresh cache was
414// written. Without this check, a cache created before the first OpenCode Go
415// login would hide the provider until the 24-hour TTL expired.
416func needsOpenCodeGoRefresh(c provider.ModelCache) bool {
417 if !CredentialAvailable(provider.ProviderOpenCodeGo) {
418 return false
419 }
420 for _, model := range c.Models {
421 if model.Provider == provider.ProviderOpenCodeGo {
422 return false
423 }
424 }
425 for _, name := range c.AuthoritativeProviders {
426 if name == provider.ProviderOpenCodeGo {
427 return false
428 }
429 }
430 return true
431}
432
433func cacheSnapshotsEqual(a provider.ModelCache, aErr error, b provider.ModelCache, bErr error) bool {
434 if (aErr != nil) != (bErr != nil) {
435 return false
436 }
437 if aErr != nil {
438 return true
439 }
440 return reflect.DeepEqual(a, b)
441}
442
443func eligibleProviderDiscoveryIncomplete(eligible map[string]struct{}, discovered map[string][]provider.Model) bool {
444 for name := range eligible {
445 if _, ok := discovered[name]; !ok {
446 return true
447 }
448 }
449 return false
450}
451
452func refreshModels(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
453 refreshModelsWithMode(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, apiKeyCommandSkip)
454}
455
456func refreshModelsWithMode(explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string, commandMode apiKeyCommandMode) {
457 refreshModelsWithContext(context.Background(), explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider, commandMode)
458}
459
460func resolveCredentialForCatalog(ctx context.Context, providerName string, mode apiKeyCommandMode) (cred, method string, err error) {
461 cred, method, _, err = resolveCredentialFull(ctx, providerName, "", mode)
462 return cred, method, err
463}
464
465func refreshModelsWithContext(parent context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string, commandMode apiKeyCommandMode) {
466 if parent == nil {
467 parent = context.Background()
468 }
469 explicitProvider = effectiveCatalogProvider(explicitProvider)
470 cached, cachedErr := provider.LoadCache(ModelCachePath())
471 ctx, cancel := context.WithTimeout(parent, 20*time.Second)
472 defer cancel()
473
474 openCodeGoCred, openCodeGoMethod := "", ""
475 if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
476 openCodeGoCred, openCodeGoMethod = explicitAPIKey, "apikey"
477 } else {
478 openCodeGoCred, openCodeGoMethod, _, _ = resolveCredentialFull(ctx, provider.ProviderOpenCodeGo, "", commandMode)
479 }
480 currentScopes := modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL)
481 openCodeGoBaseURL := ""
482 if explicitProvider == provider.ProviderOpenCodeGo {
483 openCodeGoBaseURL = explicitBaseURL
484 }
485 if openCodeGoMethod == "apikey" && openCodeGoCred != "" {
486 currentScopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(openCodeGoCred, openCodeGoBaseURL)
487 }
488 if cached.IsFresh() &&
489 cached.Version == provider.ModelCacheVersion &&
490 providerScopesEqual(cached.ProviderScopes, currentScopes) &&
491 ((onlyProvider != "" && onlyProvider != provider.ProviderOpenCodeGo) || !needsOpenCodeGoRefresh(cached)) {
492 return
493 }
494
495 discovered := make(map[string][]provider.Model)
496 authoritativeDiscovered := make(map[string]bool)
497 discoveredScopes := make(map[string]string)
498 eligibleProviders := make(map[string]struct{}, len(currentScopes))
499 for name := range currentScopes {
500 eligibleProviders[name] = struct{}{}
501 }
502 markEligible := func(name string) {
503 eligibleProviders[name] = struct{}{}
504 }
505 recordDiscovery := func(name string, models []provider.Model, authoritative bool, scope string) {
506 discovered[name] = append([]provider.Model(nil), models...)
507 if authoritative {
508 authoritativeDiscovered[name] = true
509 }
510 if scope != "" {
511 discoveredScopes[name] = scope
512 }
513 }
514 if onlyProvider == "" || onlyProvider == provider.ProviderAnthropic {
515 if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderAnthropic, commandMode); err == nil && method == "apikey" {
516 markEligible(provider.ProviderAnthropic)
517 // /v1/models on Anthropic is API-key only; OAuth tokens can
518 // also list models via the bearer header, but we skip OAuth
519 // here to avoid surprise rate-limit hits on subscription keys.
520 if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
521 recordDiscovery(provider.ProviderAnthropic, live, false, "")
522 }
523 }
524 }
525 if onlyProvider == "" || onlyProvider == provider.ProviderOpenAI {
526 if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOpenAI, commandMode); err == nil && method == "apikey" {
527 markEligible(provider.ProviderOpenAI)
528 if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
529 recordDiscovery(provider.ProviderOpenAI, live, false, "")
530 }
531 }
532 }
533 if onlyProvider == "" {
534 var ollamaKey string
535 if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
536 ollamaKey = explicitAPIKey
537 } else if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOllama, commandMode); err == nil && method == "apikey" {
538 ollamaKey = cred
539 }
540 if ollamaKey != "" {
541 markEligible(provider.ProviderOllama)
542 baseURL := provider.OllamaCloudBaseURL
543 if explicitProvider == provider.ProviderOllama && explicitBaseURL != "" {
544 baseURL = explicitBaseURL
545 }
546 if live, err := discoverOllamaFn(ctx, ollamaKey, baseURL); err == nil {
547 scope := credentialScopeForEndpoint(ollamaKey, baseURL)
548 recordDiscovery(provider.ProviderOllama, live, false, scope)
549 }
550 }
551 }
552 if onlyProvider == "" || onlyProvider == provider.ProviderOpenAICodex {
553 if cred, method, accountID, err := resolveCredentialFull(ctx, provider.ProviderOpenAICodex, "", commandMode); err == nil && method == "oauth" {
554 markEligible(provider.ProviderOpenAICodex)
555 if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
556 recordDiscovery(provider.ProviderOpenAICodex, live, accountID != "", accountID)
557 }
558 }
559 }
560 if onlyProvider == "" || onlyProvider == provider.ProviderOpenCodeGo {
561 if openCodeGoMethod == "apikey" {
562 markEligible(provider.ProviderOpenCodeGo)
563 if live, err := discoverOpenCodeGoFn(ctx, openCodeGoCred, openCodeGoBaseURL); err == nil {
564 recordDiscovery(provider.ProviderOpenCodeGo, live, true, credentialScopeForEndpoint(openCodeGoCred, openCodeGoBaseURL))
565 }
566 }
567 }
568 if onlyProvider == "" || onlyProvider == provider.ProviderKimi {
569 if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderKimi, commandMode); err == nil && method == "apikey" {
570 markEligible(provider.ProviderKimi)
571 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
572 for i := range live {
573 live[i].Provider = provider.ProviderKimi
574 live[i].Source = "live"
575 }
576 recordDiscovery(provider.ProviderKimi, live, false, "")
577 }
578 }
579 }
580 if onlyProvider == "" || onlyProvider == provider.ProviderGoogle {
581 if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderGoogle, commandMode); err == nil && method == "apikey" {
582 markEligible(provider.ProviderGoogle)
583 if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
584 recordDiscovery(provider.ProviderGoogle, live, false, "")
585 }
586 }
587 }
588 if onlyProvider == "" || onlyProvider == provider.ProviderOpenRouter {
589 if _, _, err := resolveCredentialForCatalog(ctx, provider.ProviderOpenRouter, commandMode); err == nil {
590 markEligible(provider.ProviderOpenRouter)
591 // /models is public; gate on a credential so the picker only
592 // fills with OpenRouter's hundreds of routes for users who use it.
593 if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
594 recordDiscovery(provider.ProviderOpenRouter, live, false, "")
595 }
596 }
597 }
598
599 if len(discovered) == 0 || ctx.Err() != nil {
600 return
601 }
602
603 modelCatalogMu.Lock()
604 defer modelCatalogMu.Unlock()
605 if ctx.Err() != nil {
606 return
607 }
608 latestSnapshot, latestErr := provider.LoadCache(ModelCachePath())
609 latest := latestSnapshot
610 if latest.Version != provider.ModelCacheVersion {
611 // Routing metadata from an older cache format is not safe to merge
612 // into a newly discovered catalog. Treat it as empty, but compare the
613 // raw snapshot below so a concurrent refresh is still detected.
614 latest = provider.ModelCache{}
615 }
616 preserveFullRefreshFreshness := onlyProvider == "" && eligibleProviderDiscoveryIncomplete(eligibleProviders, discovered)
617 var all []provider.Model
618 var authoritativeProviders []string
619 var providerScopes map[string]string
620 _, latestHasOpenCodeGo := latestSnapshot.ProviderScopes[provider.ProviderOpenCodeGo]
621 keepOpenCodeGo := !cacheSnapshotsEqual(cached, cachedErr, latestSnapshot, latestErr) && latestHasOpenCodeGo
622 if keepOpenCodeGo {
623 // Another runtime replaced the cache while this discovery was in
624 // flight and owns newer OpenCode Go scope data. Keep its scope and
625 // still publish any unrelated providers that this refresh
626 // discovered. When the newer snapshot carries no OpenCode Go scope,
627 // this refresh's own discovery is the only such data and is
628 // published below instead of being discarded.
629 delete(discovered, provider.ProviderOpenCodeGo)
630 delete(authoritativeDiscovered, provider.ProviderOpenCodeGo)
631 delete(discoveredScopes, provider.ProviderOpenCodeGo)
632 }
633 if len(discovered) == 0 {
634 return
635 }
636 if keepOpenCodeGo {
637 latest = filterCacheByProviderScopesExcept(latest, currentScopes, provider.ProviderOpenCodeGo)
638 } else {
639 latest = filterCacheByProviderScopes(latest, currentScopes)
640 }
641 all = append([]provider.Model(nil), latest.Models...)
642 authoritativeProviders = append([]string(nil), latest.AuthoritativeProviders...)
643 providerScopes = make(map[string]string, len(latest.ProviderScopes))
644 for name, scope := range latest.ProviderScopes {
645 providerScopes[name] = scope
646 }
647 for name, live := range discovered {
648 all = filterModelsByProvider(all, name)
649 authoritativeProviders = filterProviderNames(authoritativeProviders, name)
650 delete(providerScopes, name)
651 all = append(all, live...)
652 if authoritativeDiscovered[name] {
653 authoritativeProviders = append(authoritativeProviders, name)
654 }
655 if scope := discoveredScopes[name]; scope != "" {
656 providerScopes[name] = scope
657 }
658 }
659 if len(all) == 0 && len(authoritativeProviders) == 0 {
660 return
661 }
662 if ctx.Err() != nil {
663 return
664 }
665 provider.SetLiveModelsForProviders(all, authoritativeProviders)
666 fetchedAt := time.Now().UTC()
667 if onlyProvider != "" {
668 if latest.Version == provider.ModelCacheVersion {
669 // A partial refresh must not make an otherwise stale full
670 // catalog look fresh to the next CLI refresh.
671 fetchedAt = latest.FetchedAt
672 } else {
673 fetchedAt = time.Time{}
674 }
675 } else if preserveFullRefreshFreshness {
676 // A failed eligible discovery must not make stale metadata appear
677 // fresh merely because another provider refreshed successfully.
678 fetchedAt = latest.FetchedAt
679 }
680 if ctx.Err() != nil {
681 return
682 }
683 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
684 Version: provider.ModelCacheVersion,
685 FetchedAt: fetchedAt,
686 Models: all,
687 AuthoritativeProviders: authoritativeProviders,
688 ProviderScopes: providerScopes,
689 })
690}