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}