1package agent
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/bnema/zut/packages/provider"
12)
13
14// ModelCachePath returns the on-disk location of the merged model cache.
15func ModelCachePath() string {
16 return filepath.Join(ZutHome(), "models-cache.json")
17}
18
19// UserModelsPath returns the path to the user's models.json override.
20func UserModelsPath() string {
21 return filepath.Join(ZutHome(), "models.json")
22}
23
24// LoadCachedModels loads the cache file and applies it to the provider
25// package so FindModel / ModelsForProvider see live ids immediately.
26// Safe to call before any credentials are known.
27func LoadCachedModels() {
28 c, err := provider.LoadCache(ModelCachePath())
29 if err != nil {
30 return
31 }
32 c = filterCacheByProviderScopes(c, currentModelProviderScopes())
33 if len(c.Models) > 0 || len(c.AuthoritativeProviders) > 0 {
34 provider.SetLiveModelsForProviders(c.Models, c.AuthoritativeProviders)
35 }
36}
37
38// currentModelProviderScopes returns identifiers that make a provider's
39// discovered catalog account-specific. The cache remains provider-neutral:
40// each provider supplies an opaque scope value when it needs one.
41func currentModelProviderScopes() map[string]string {
42 scopes := make(map[string]string)
43 if token := loadOAuthToken("openai"); token != nil && token.AccountID != "" {
44 scopes["openai-codex"] = token.AccountID
45 }
46 return scopes
47}
48
49// filterCacheByProviderScopes removes cached entries whose provider scope no
50// longer matches the active credential. It also removes the authoritative
51// marker so that a failed refresh falls back to the baked-in catalog instead
52// of treating another account's empty catalog as authoritative.
53func filterCacheByProviderScopes(c provider.ModelCache, scopes map[string]string) provider.ModelCache {
54 out := provider.ModelCache{
55 FetchedAt: c.FetchedAt,
56 Models: append([]provider.Model(nil), c.Models...),
57 AuthoritativeProviders: append([]string(nil), c.AuthoritativeProviders...),
58 ProviderScopes: make(map[string]string, len(c.ProviderScopes)),
59 }
60 for name, scope := range c.ProviderScopes {
61 out.ProviderScopes[name] = scope
62 }
63
64 scopedProviders := map[string]struct{}{"openai-codex": {}}
65 for name := range c.ProviderScopes {
66 scopedProviders[name] = struct{}{}
67 }
68 for name := range scopes {
69 scopedProviders[name] = struct{}{}
70 }
71 for name := range scopedProviders {
72 if cachedScope := c.ProviderScopes[name]; cachedScope != "" && cachedScope == scopes[name] {
73 continue
74 }
75 out.Models = filterModelsByProvider(out.Models, name)
76 out.AuthoritativeProviders = filterProviderNames(out.AuthoritativeProviders, name)
77 delete(out.ProviderScopes, name)
78 }
79 return out
80}
81
82func filterModelsByProvider(models []provider.Model, name string) []provider.Model {
83 out := make([]provider.Model, 0, len(models))
84 for _, model := range models {
85 if model.Provider != name {
86 out = append(out, model)
87 }
88 }
89 return out
90}
91
92func filterProviderNames(names []string, target string) []string {
93 out := make([]string, 0, len(names))
94 for _, name := range names {
95 if name != target {
96 out = append(out, name)
97 }
98 }
99 return out
100}
101
102func providerScopesEqual(a, b map[string]string) bool {
103 if len(a) != len(b) {
104 return false
105 }
106 for name, scope := range a {
107 if b[name] != scope {
108 return false
109 }
110 }
111 return true
112}
113
114// LoadUserModels reads $ZUT_HOME/models.json and merges any user-defined
115// models into the active catalog. User models take highest precedence.
116// Any validation issues (bad provider id, empty model id, malformed
117// JSON, negative widths) are surfaced as one warning per line on stderr;
118// the well-formed entries from the rest of the file are still loaded.
119func LoadUserModels() {
120 models, warnings := provider.LoadUserModelsWithWarnings(UserModelsPath())
121 for _, w := range warnings {
122 fmt.Fprintln(os.Stderr, "zut:", w)
123 }
124 provider.SetUserModels(models)
125}
126
127// isGatewayProvider returns true for providers whose OpenAI-compatible
128// endpoint can accept routed model IDs that are not present in zut's local
129// catalog. Vercel AI Gateway is intentionally not listed here: zut currently
130// talks to it through the Anthropic-compatible client, which still requires
131// catalog metadata for request shaping.
132func isGatewayProvider(prov string) bool {
133 switch prov {
134 case "openrouter", "cloudflare-ai-gateway":
135 return true
136 default:
137 return false
138 }
139}
140
141// isGatewayRoutedModelID reports whether a model looks like the routed IDs
142// used by gateway providers, for example "deepseek/deepseek-v4-flash".
143// Non-routed typos like "deepseek-v4-flashh" should still be repaired to a
144// known default instead of being silently accepted.
145func isGatewayRoutedModelID(model string) bool {
146 return strings.Contains(strings.TrimSpace(model), "/")
147}
148
149// ValidateAndRepairConfig checks the persisted config.json's
150// (Provider, Model) pair against the active catalog and repairs any
151// mismatch in-place (and on disk) before any UI renders. Three failure
152// modes are handled:
153//
154// - cfg.Provider is empty or unknown -> reset to "anthropic".
155// - cfg.Model is empty -> set to the provider's default.
156// - cfg.Model belongs to a different provider than cfg.Provider
157// (e.g. provider=anthropic + model=kimi-for-coding from a stale
158// half-applied switch) -> reset model to the provider's default.
159//
160// Gateway providers are exempt from the cross-provider model check for routed
161// model IDs because those IDs can be valid even when absent from zut's catalog.
162//
163// Silent on success; one stderr line per repair. Errors loading or
164// saving the file are non-fatal — the caller continues with defaults.
165func ValidateAndRepairConfig() {
166 cfg, err := LoadConfig()
167 if err != nil {
168 fmt.Fprintf(os.Stderr, "zut: config.json: %v (using defaults)\n", err)
169 return
170 }
171 changed := false
172
173 if cfg.Provider != "" && !isKnownProvider(cfg.Provider) {
174 fmt.Fprintf(os.Stderr, "zut: config.json: unknown provider %q reset to \"anthropic\"\n", cfg.Provider)
175 cfg.Provider = "anthropic"
176 cfg.Model = ""
177 changed = true
178 }
179
180 if cfg.Provider != "" && cfg.Model != "" {
181 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {
182 // Gateway providers can serve routed model ids like
183 // "deepseek/deepseek-v4-flash" even when the local catalog does not
184 // know them. Preserve only routed ids; plain typos are still repaired.
185 if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) {
186 // Provider is a router and the id is route-qualified; preserve it.
187 } else if m, err := provider.FindModel("", cfg.Model); err == nil {
188 fix := defaultModelForProvider(cfg.Provider)
189 fmt.Fprintf(os.Stderr,
190 "zut: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
191 cfg.Model, m.Provider, cfg.Provider, fix)
192 cfg.Model = fix
193 changed = true
194 } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID {
195 // Model id not in any catalog. Reset to provider's default.
196 fix := defaultModelForProvider(cfg.Provider)
197 fmt.Fprintf(os.Stderr,
198 "zut: config.json: model %q not found in the active catalog; switched to %q\n",
199 cfg.Model, fix)
200 cfg.Model = fix
201 changed = true
202 }
203 }
204 }
205
206 if changed {
207 if err := SaveConfig(cfg); err != nil {
208 fmt.Fprintf(os.Stderr, "zut: config.json: failed to persist repair: %v\n", err)
209 }
210 }
211}
212
213// RefreshModelsAsync kicks a background discovery for every provider
214// we have credentials for. Refreshed results are merged into the
215// active catalog and persisted to the on-disk cache.
216//
217// Silent on error: discovery is a nice-to-have. Callers can still use
218// the baked-in catalog if this fails.
219func RefreshModelsAsync() {
220 go refreshModels()
221 go func() {
222 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
223 defer cancel()
224 _ = refreshLlamaCPPModels(ctx, apiKeyCommandSkip)
225 }()
226}
227
228// RefreshLlamaCPPModels adds the router's currently loaded models to the
229// active catalog. Unloaded models remain in the management UI and cannot be
230// selected for inference until they are loaded.
231func RefreshLlamaCPPModels(ctx context.Context) error {
232 return refreshLlamaCPPModels(ctx, apiKeyCommandExecute)
233}
234
235func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) error {
236 baseURL, apiKey, err := resolveLlamaCPPConfig(ctx, commandMode)
237 if err != nil || baseURL == "" {
238 return err
239 }
240 client, err := provider.NewLlamaCPPClient(baseURL, apiKey)
241 if err != nil {
242 return err
243 }
244 models, err := client.List(ctx, false)
245 if err != nil {
246 return err
247 }
248 provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))
249 return nil
250}
251
252func refreshModels() {
253 cached, _ := provider.LoadCache(ModelCachePath())
254 currentScopes := currentModelProviderScopes()
255 if cached.IsFresh() && providerScopesEqual(cached.ProviderScopes, currentScopes) {
256 return
257 }
258
259 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
260 defer cancel()
261
262 var all []provider.Model
263 var authoritativeProviders []string
264 providerScopes := make(map[string]string)
265 if cached.IsFresh() {
266 cached = filterCacheByProviderScopes(cached, currentScopes)
267 all = append(all, cached.Models...)
268 authoritativeProviders = append(authoritativeProviders, cached.AuthoritativeProviders...)
269 for name, scope := range cached.ProviderScopes {
270 providerScopes[name] = scope
271 }
272 }
273
274 if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
275 // /v1/models on Anthropic is API-key only; OAuth tokens can
276 // also list models via the bearer header, but we skip OAuth
277 // here to avoid surprise rate-limit hits on subscription keys.
278 if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
279 all = append(all, live...)
280 }
281 }
282 if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
283 if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
284 all = append(all, live...)
285 }
286 }
287 if cred, method, accountID, err := resolveCredentialFull(ctx, "openai-codex", "", apiKeyCommandSkip); err == nil && method == "oauth" {
288 if live, err := provider.DiscoverOpenAICodex(ctx, cred, accountID, ""); err == nil {
289 all = filterModelsByProvider(all, "openai-codex")
290 authoritativeProviders = filterProviderNames(authoritativeProviders, "openai-codex")
291 delete(providerScopes, "openai-codex")
292 all = append(all, live...)
293 if accountID != "" {
294 authoritativeProviders = append(authoritativeProviders, "openai-codex")
295 providerScopes["openai-codex"] = accountID
296 }
297 }
298 }
299 if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
300 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
301 for i := range live {
302 live[i].Provider = "kimi"
303 live[i].Source = "live"
304 }
305 all = append(all, live...)
306 }
307 }
308 if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
309 if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
310 all = append(all, live...)
311 }
312 }
313 if _, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
314 // /models is public; gate on a credential so the picker only
315 // fills with OpenRouter's hundreds of routes for users who use it.
316 if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
317 all = append(all, live...)
318 }
319 }
320
321 if len(all) == 0 && len(authoritativeProviders) == 0 {
322 return
323 }
324 provider.SetLiveModelsForProviders(all, authoritativeProviders)
325 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
326 FetchedAt: time.Now().UTC(),
327 Models: all,
328 AuthoritativeProviders: authoritativeProviders,
329 ProviderScopes: providerScopes,
330 })
331}