From b245be02e5ce1ff5f93b69003e7ecd6cef4af274 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Thu, 23 Apr 2026 23:09:32 +0200 Subject: [PATCH] feat(models): support user-defined models via models.json Reads $ZOT_HOME/models.json at startup and merges user-defined models into the active catalog with highest precedence. Provider keys like openai-codex are normalized. Documented in README. --- README.md | 28 ++++++ internal/agent/cli.go | 1 + internal/agent/modelsync.go | 12 +++ internal/provider/usermodels.go | 158 ++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 internal/provider/usermodels.go diff --git a/README.md b/README.md index 25ebf4a..1ece0e4 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,34 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed. +### Custom models + +Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries: + +```json +{ + "providers": { + "openai": { + "models": [ + { + "id": "gpt-5.5", + "name": "GPT-5.5", + "reasoning": true, + "contextWindow": 400000, + "maxTokens": 128000 + } + ] + } + } +} +``` + +Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. + +Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`. + +User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored. + ## Inline images When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **iTerm2**, **WezTerm**, **Kitty**, **Ghostty**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: diff --git a/internal/agent/cli.go b/internal/agent/cli.go index 7252214..ffb41c5 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -172,6 +172,7 @@ func Run(rawArgs []string, version string) error { // Model catalog: load any cached discovery data before we inspect // the model list (list-models, print/json, interactive). LoadCachedModels() + LoadUserModels() if args.ListModels { printModels() diff --git a/internal/agent/modelsync.go b/internal/agent/modelsync.go index d39ef8b..358bd94 100644 --- a/internal/agent/modelsync.go +++ b/internal/agent/modelsync.go @@ -13,6 +13,11 @@ func ModelCachePath() string { return filepath.Join(ZotHome(), "models-cache.json") } +// UserModelsPath returns the path to the user's models.json override. +func UserModelsPath() string { + return filepath.Join(ZotHome(), "models.json") +} + // LoadCachedModels loads the cache file and applies it to the provider // package so FindModel / ModelsForProvider see live ids immediately. // Safe to call before any credentials are known. @@ -26,6 +31,13 @@ func LoadCachedModels() { } } +// LoadUserModels reads $ZOT_HOME/models.json and merges any user-defined +// models into the active catalog. User models take highest precedence. +func LoadUserModels() { + models := provider.LoadUserModels(UserModelsPath()) + provider.SetUserModels(models) +} + // RefreshModelsAsync kicks a background discovery for every provider // we have credentials for. Refreshed results are merged into the // active catalog and persisted to the on-disk cache. diff --git a/internal/provider/usermodels.go b/internal/provider/usermodels.go new file mode 100644 index 0000000..79543e2 --- /dev/null +++ b/internal/provider/usermodels.go @@ -0,0 +1,158 @@ +package provider + +import ( + "encoding/json" + "os" +) + +// UserModelsFile is the JSON format for user-defined models. +// Place a models.json in $ZOT_HOME to add models that aren't in the +// baked-in catalog or to override catalog entries. +// +// Example: +// +// { +// "providers": { +// "openai": { +// "models": [ +// { +// "id": "gpt-5.5", +// "name": "GPT-5.5", +// "reasoning": true, +// "contextWindow": 400000, +// "maxTokens": 128000, +// "priceInput": 2.50, +// "priceOutput": 15.00, +// "priceCacheRead": 0.25 +// } +// ] +// } +// } +// } +type UserModelsFile struct { + Providers map[string]UserProvider `json:"providers"` +} + +// UserProvider groups models under a provider key. +type UserProvider struct { + Models []UserModel `json:"models"` +} + +// UserModel is a single model entry in the user's models.json. +type UserModel struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + PriceInput float64 `json:"priceInput"` + PriceOutput float64 `json:"priceOutput"` + PriceCacheRead float64 `json:"priceCacheRead"` + PriceCacheWrite float64 `json:"priceCacheWrite"` + Input []string `json:"input"` // informational only + API string `json:"api"` // informational only +} + +// LoadUserModels reads a models.json file and returns the models +// converted to the internal Model type. Returns nil on any error +// (missing file, bad JSON, etc.) so the caller can treat it as +// optional without error handling. +func LoadUserModels(path string) []Model { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var file UserModelsFile + if err := json.Unmarshal(data, &file); err != nil { + return nil + } + + var out []Model + for providerName, prov := range file.Providers { + // Normalize provider name: strip suffixes like "-codex" so + // "openai-codex" maps to "openai". + normalized := providerName + switch providerName { + case "openai-codex", "openai-responses": + normalized = "openai" + case "anthropic-messages": + normalized = "anthropic" + } + + for _, um := range prov.Models { + m := Model{ + Provider: normalized, + ID: um.ID, + DisplayName: um.Name, + ContextWindow: um.ContextWindow, + MaxOutput: um.MaxTokens, + Reasoning: um.Reasoning, + PriceInput: um.PriceInput, + PriceOutput: um.PriceOutput, + PriceCacheRead: um.PriceCacheRead, + PriceCacheWrite: um.PriceCacheWrite, + Source: "user", + } + if m.DisplayName == "" { + m.DisplayName = m.ID + } + out = append(out, m) + } + } + return out +} + +// SetUserModels merges user-defined models into the active catalog. +// User models take precedence over both the baked-in catalog and +// live-discovered models. +func SetUserModels(models []Model) { + if len(models) == 0 { + return + } + activeMu.Lock() + defer activeMu.Unlock() + + // Build index of current active models. + byKey := func(p, id string) string { return p + "/" + id } + index := make(map[string]int, len(active)) + for i, m := range active { + index[byKey(m.Provider, m.ID)] = i + } + + for _, um := range models { + k := byKey(um.Provider, um.ID) + if idx, ok := index[k]; ok { + // Override existing entry but keep prices from user if + // they provided them, otherwise keep catalog prices. + existing := active[idx] + if um.PriceInput > 0 { + existing.PriceInput = um.PriceInput + } + if um.PriceOutput > 0 { + existing.PriceOutput = um.PriceOutput + } + if um.PriceCacheRead > 0 { + existing.PriceCacheRead = um.PriceCacheRead + } + if um.PriceCacheWrite > 0 { + existing.PriceCacheWrite = um.PriceCacheWrite + } + if um.DisplayName != "" { + existing.DisplayName = um.DisplayName + } + if um.ContextWindow > 0 { + existing.ContextWindow = um.ContextWindow + } + if um.MaxOutput > 0 { + existing.MaxOutput = um.MaxOutput + } + existing.Reasoning = um.Reasoning + existing.Source = "user" + existing.Speculative = false + active[idx] = existing + } else { + // New model not in catalog. + active = append(active, um) + } + } +}