v0.27.1 131edba

modelsync.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close