1package agent
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "time"
9
10 "github.com/patriceckhart/zot/internal/provider"
11)
12
13// ModelCachePath returns the on-disk location of the merged model cache.
14func ModelCachePath() string {
15 return filepath.Join(ZotHome(), "models-cache.json")
16}
17
18// UserModelsPath returns the path to the user's models.json override.
19func UserModelsPath() string {
20 return filepath.Join(ZotHome(), "models.json")
21}
22
23// LoadCachedModels loads the cache file and applies it to the provider
24// package so FindModel / ModelsForProvider see live ids immediately.
25// Safe to call before any credentials are known.
26func LoadCachedModels() {
27 c, err := provider.LoadCache(ModelCachePath())
28 if err != nil {
29 return
30 }
31 if len(c.Models) > 0 {
32 provider.SetLiveModels(c.Models)
33 }
34}
35
36// LoadUserModels reads $ZOT_HOME/models.json and merges any user-defined
37// models into the active catalog. User models take highest precedence.
38// Any validation issues (bad provider id, empty model id, malformed
39// JSON, negative widths) are surfaced as one warning per line on stderr;
40// the well-formed entries from the rest of the file are still loaded.
41func LoadUserModels() {
42 models, warnings := provider.LoadUserModelsWithWarnings(UserModelsPath())
43 for _, w := range warnings {
44 fmt.Fprintln(os.Stderr, "zot:", w)
45 }
46 provider.SetUserModels(models)
47}
48
49// ValidateAndRepairConfig checks the persisted config.json's
50// (Provider, Model) pair against the active catalog and repairs any
51// mismatch in-place (and on disk) before any UI renders. Three failure
52// modes are handled:
53//
54// - cfg.Provider is empty or unknown -> reset to "anthropic".
55// - cfg.Model is empty -> set to the provider's default.
56// - cfg.Model belongs to a different provider than cfg.Provider
57// (e.g. provider=anthropic + model=kimi-for-coding from a stale
58// half-applied switch) -> reset model to the provider's default.
59//
60// Silent on success; one stderr line per repair. Errors loading or
61// saving the file are non-fatal — the caller continues with defaults.
62func ValidateAndRepairConfig() {
63 cfg, err := LoadConfig()
64 if err != nil {
65 fmt.Fprintf(os.Stderr, "zot: config.json: %v (using defaults)\n", err)
66 return
67 }
68 changed := false
69
70 if cfg.Provider != "" && !isKnownProvider(cfg.Provider) {
71 fmt.Fprintf(os.Stderr, "zot: config.json: unknown provider %q reset to \"anthropic\"\n", cfg.Provider)
72 cfg.Provider = "anthropic"
73 cfg.Model = ""
74 changed = true
75 }
76
77 if cfg.Provider != "" && cfg.Model != "" {
78 if m, err := provider.FindModel("", cfg.Model); err == nil {
79 if m.Provider != cfg.Provider {
80 fix := defaultModelForProvider(cfg.Provider)
81 fmt.Fprintf(os.Stderr,
82 "zot: config.json: model %q belongs to provider %q (config has provider=%q); switched model to %q\n",
83 cfg.Model, m.Provider, cfg.Provider, fix)
84 cfg.Model = fix
85 changed = true
86 }
87 } else if cfg.Provider != "ollama" {
88 // Model id not in any catalog. Reset to provider's default.
89 fix := defaultModelForProvider(cfg.Provider)
90 fmt.Fprintf(os.Stderr,
91 "zot: config.json: model %q not found in the active catalog; switched to %q\n",
92 cfg.Model, fix)
93 cfg.Model = fix
94 changed = true
95 }
96 }
97
98 if changed {
99 if err := SaveConfig(cfg); err != nil {
100 fmt.Fprintf(os.Stderr, "zot: config.json: failed to persist repair: %v\n", err)
101 }
102 }
103}
104
105// RefreshModelsAsync kicks a background discovery for every provider
106// we have credentials for. Refreshed results are merged into the
107// active catalog and persisted to the on-disk cache.
108//
109// Silent on error: discovery is a nice-to-have. Callers can still use
110// the baked-in catalog if this fails.
111func RefreshModelsAsync() {
112 go refreshModels()
113}
114
115func refreshModels() {
116 cached, _ := provider.LoadCache(ModelCachePath())
117 if cached.IsFresh() {
118 return
119 }
120
121 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
122 defer cancel()
123
124 var all []provider.Model
125
126 if cred, method, err := ResolveCredential("anthropic", ""); err == nil && method == "apikey" {
127 // /v1/models on Anthropic is API-key only; OAuth tokens can
128 // also list models via the bearer header, but we skip OAuth
129 // here to avoid surprise rate-limit hits on subscription keys.
130 if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
131 all = append(all, live...)
132 }
133 }
134 if cred, method, err := ResolveCredential("openai", ""); err == nil && method == "apikey" {
135 if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
136 all = append(all, live...)
137 }
138 }
139 if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" {
140 if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
141 for i := range live {
142 live[i].Provider = "kimi"
143 live[i].Source = "live"
144 }
145 all = append(all, live...)
146 }
147 }
148 if cred, method, err := ResolveCredential("google", ""); err == nil && method == "apikey" {
149 if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
150 all = append(all, live...)
151 }
152 }
153
154 if len(all) == 0 {
155 return
156 }
157 provider.SetLiveModels(all)
158 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
159 FetchedAt: time.Now().UTC(),
160 Models: all,
161 })
162}