detached b245be0

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.
commit
b245be02e5ce1ff5f93b69003e7ecd6cef4af274
author
patriceckhart · · 2026-04-23 21:09 UTC
parent
47c1549
signature
unsigned
refs
tag: v0.1.4
4 files · +199 -0
M README.md +28 -0
@@ -260,6 +260,34 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans
260260
261261 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
262262
263+### Custom models
264+
265+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:
266+
267+```json
268+{
269+ "providers": {
270+ "openai": {
271+ "models": [
272+ {
273+ "id": "gpt-5.5",
274+ "name": "GPT-5.5",
275+ "reasoning": true,
276+ "contextWindow": 400000,
277+ "maxTokens": 128000
278+ }
279+ ]
280+ }
281+ }
282+}
283+```
284+
285+Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
286+
287+Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.
288+
289+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.
290+
263291 ## Inline images
264292
265293 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:
@@ -260,6 +260,34 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans
260 260
261 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.261 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
262 262
263+### Custom models
264+
265+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:
266+
267+```json
268+{
269+ "providers": {
270+ "openai": {
271+ "models": [
272+ {
273+ "id": "gpt-5.5",
274+ "name": "GPT-5.5",
275+ "reasoning": true,
276+ "contextWindow": 400000,
277+ "maxTokens": 128000
278+ }
279+ ]
280+ }
281+ }
282+}
283+```
284+
285+Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
286+
287+Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.
288+
289+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.
290+
263 ## Inline images291 ## Inline images
264 292
265 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:293 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:
M internal/agent/cli.go +1 -0
@@ -172,6 +172,7 @@ func Run(rawArgs []string, version string) error {
172172 // Model catalog: load any cached discovery data before we inspect
173173 // the model list (list-models, print/json, interactive).
174174 LoadCachedModels()
175+ LoadUserModels()
175176
176177 if args.ListModels {
177178 printModels()
@@ -172,6 +172,7 @@ func Run(rawArgs []string, version string) error {
172 // Model catalog: load any cached discovery data before we inspect172 // Model catalog: load any cached discovery data before we inspect
173 // the model list (list-models, print/json, interactive).173 // the model list (list-models, print/json, interactive).
174 LoadCachedModels()174 LoadCachedModels()
175+ LoadUserModels()
175 176
176 if args.ListModels {177 if args.ListModels {
177 printModels()178 printModels()
M internal/agent/modelsync.go +12 -0
@@ -13,6 +13,11 @@ func ModelCachePath() string {
1313 return filepath.Join(ZotHome(), "models-cache.json")
1414 }
1515
16+// UserModelsPath returns the path to the user's models.json override.
17+func UserModelsPath() string {
18+ return filepath.Join(ZotHome(), "models.json")
19+}
20+
1621 // LoadCachedModels loads the cache file and applies it to the provider
1722 // package so FindModel / ModelsForProvider see live ids immediately.
1823 // Safe to call before any credentials are known.
@@ -26,6 +31,13 @@ func LoadCachedModels() {
2631 }
2732 }
2833
34+// LoadUserModels reads $ZOT_HOME/models.json and merges any user-defined
35+// models into the active catalog. User models take highest precedence.
36+func LoadUserModels() {
37+ models := provider.LoadUserModels(UserModelsPath())
38+ provider.SetUserModels(models)
39+}
40+
2941 // RefreshModelsAsync kicks a background discovery for every provider
3042 // we have credentials for. Refreshed results are merged into the
3143 // active catalog and persisted to the on-disk cache.
@@ -13,6 +13,11 @@ func ModelCachePath() string {
13 return filepath.Join(ZotHome(), "models-cache.json")13 return filepath.Join(ZotHome(), "models-cache.json")
14 }14 }
15 15
16+// UserModelsPath returns the path to the user's models.json override.
17+func UserModelsPath() string {
18+ return filepath.Join(ZotHome(), "models.json")
19+}
20+
16 // LoadCachedModels loads the cache file and applies it to the provider21 // LoadCachedModels loads the cache file and applies it to the provider
17 // package so FindModel / ModelsForProvider see live ids immediately.22 // package so FindModel / ModelsForProvider see live ids immediately.
18 // Safe to call before any credentials are known.23 // Safe to call before any credentials are known.
@@ -26,6 +31,13 @@ func LoadCachedModels() {
26 }31 }
27 }32 }
28 33
34+// LoadUserModels reads $ZOT_HOME/models.json and merges any user-defined
35+// models into the active catalog. User models take highest precedence.
36+func LoadUserModels() {
37+ models := provider.LoadUserModels(UserModelsPath())
38+ provider.SetUserModels(models)
39+}
40+
29 // RefreshModelsAsync kicks a background discovery for every provider41 // RefreshModelsAsync kicks a background discovery for every provider
30 // we have credentials for. Refreshed results are merged into the42 // we have credentials for. Refreshed results are merged into the
31 // active catalog and persisted to the on-disk cache.43 // active catalog and persisted to the on-disk cache.
A internal/provider/usermodels.go +158 -0
@@ -0,0 +1,158 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "os"
6+)
7+
8+// UserModelsFile is the JSON format for user-defined models.
9+// Place a models.json in $ZOT_HOME to add models that aren't in the
10+// baked-in catalog or to override catalog entries.
11+//
12+// Example:
13+//
14+// {
15+// "providers": {
16+// "openai": {
17+// "models": [
18+// {
19+// "id": "gpt-5.5",
20+// "name": "GPT-5.5",
21+// "reasoning": true,
22+// "contextWindow": 400000,
23+// "maxTokens": 128000,
24+// "priceInput": 2.50,
25+// "priceOutput": 15.00,
26+// "priceCacheRead": 0.25
27+// }
28+// ]
29+// }
30+// }
31+// }
32+type UserModelsFile struct {
33+ Providers map[string]UserProvider `json:"providers"`
34+}
35+
36+// UserProvider groups models under a provider key.
37+type UserProvider struct {
38+ Models []UserModel `json:"models"`
39+}
40+
41+// UserModel is a single model entry in the user's models.json.
42+type UserModel struct {
43+ ID string `json:"id"`
44+ Name string `json:"name"`
45+ Reasoning bool `json:"reasoning"`
46+ ContextWindow int `json:"contextWindow"`
47+ MaxTokens int `json:"maxTokens"`
48+ PriceInput float64 `json:"priceInput"`
49+ PriceOutput float64 `json:"priceOutput"`
50+ PriceCacheRead float64 `json:"priceCacheRead"`
51+ PriceCacheWrite float64 `json:"priceCacheWrite"`
52+ Input []string `json:"input"` // informational only
53+ API string `json:"api"` // informational only
54+}
55+
56+// LoadUserModels reads a models.json file and returns the models
57+// converted to the internal Model type. Returns nil on any error
58+// (missing file, bad JSON, etc.) so the caller can treat it as
59+// optional without error handling.
60+func LoadUserModels(path string) []Model {
61+ data, err := os.ReadFile(path)
62+ if err != nil {
63+ return nil
64+ }
65+ var file UserModelsFile
66+ if err := json.Unmarshal(data, &file); err != nil {
67+ return nil
68+ }
69+
70+ var out []Model
71+ for providerName, prov := range file.Providers {
72+ // Normalize provider name: strip suffixes like "-codex" so
73+ // "openai-codex" maps to "openai".
74+ normalized := providerName
75+ switch providerName {
76+ case "openai-codex", "openai-responses":
77+ normalized = "openai"
78+ case "anthropic-messages":
79+ normalized = "anthropic"
80+ }
81+
82+ for _, um := range prov.Models {
83+ m := Model{
84+ Provider: normalized,
85+ ID: um.ID,
86+ DisplayName: um.Name,
87+ ContextWindow: um.ContextWindow,
88+ MaxOutput: um.MaxTokens,
89+ Reasoning: um.Reasoning,
90+ PriceInput: um.PriceInput,
91+ PriceOutput: um.PriceOutput,
92+ PriceCacheRead: um.PriceCacheRead,
93+ PriceCacheWrite: um.PriceCacheWrite,
94+ Source: "user",
95+ }
96+ if m.DisplayName == "" {
97+ m.DisplayName = m.ID
98+ }
99+ out = append(out, m)
100+ }
101+ }
102+ return out
103+}
104+
105+// SetUserModels merges user-defined models into the active catalog.
106+// User models take precedence over both the baked-in catalog and
107+// live-discovered models.
108+func SetUserModels(models []Model) {
109+ if len(models) == 0 {
110+ return
111+ }
112+ activeMu.Lock()
113+ defer activeMu.Unlock()
114+
115+ // Build index of current active models.
116+ byKey := func(p, id string) string { return p + "/" + id }
117+ index := make(map[string]int, len(active))
118+ for i, m := range active {
119+ index[byKey(m.Provider, m.ID)] = i
120+ }
121+
122+ for _, um := range models {
123+ k := byKey(um.Provider, um.ID)
124+ if idx, ok := index[k]; ok {
125+ // Override existing entry but keep prices from user if
126+ // they provided them, otherwise keep catalog prices.
127+ existing := active[idx]
128+ if um.PriceInput > 0 {
129+ existing.PriceInput = um.PriceInput
130+ }
131+ if um.PriceOutput > 0 {
132+ existing.PriceOutput = um.PriceOutput
133+ }
134+ if um.PriceCacheRead > 0 {
135+ existing.PriceCacheRead = um.PriceCacheRead
136+ }
137+ if um.PriceCacheWrite > 0 {
138+ existing.PriceCacheWrite = um.PriceCacheWrite
139+ }
140+ if um.DisplayName != "" {
141+ existing.DisplayName = um.DisplayName
142+ }
143+ if um.ContextWindow > 0 {
144+ existing.ContextWindow = um.ContextWindow
145+ }
146+ if um.MaxOutput > 0 {
147+ existing.MaxOutput = um.MaxOutput
148+ }
149+ existing.Reasoning = um.Reasoning
150+ existing.Source = "user"
151+ existing.Speculative = false
152+ active[idx] = existing
153+ } else {
154+ // New model not in catalog.
155+ active = append(active, um)
156+ }
157+ }
158+}
@@ -0,0 +1,158 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "os"
6+)
7+
8+// UserModelsFile is the JSON format for user-defined models.
9+// Place a models.json in $ZOT_HOME to add models that aren't in the
10+// baked-in catalog or to override catalog entries.
11+//
12+// Example:
13+//
14+// {
15+// "providers": {
16+// "openai": {
17+// "models": [
18+// {
19+// "id": "gpt-5.5",
20+// "name": "GPT-5.5",
21+// "reasoning": true,
22+// "contextWindow": 400000,
23+// "maxTokens": 128000,
24+// "priceInput": 2.50,
25+// "priceOutput": 15.00,
26+// "priceCacheRead": 0.25
27+// }
28+// ]
29+// }
30+// }
31+// }
32+type UserModelsFile struct {
33+ Providers map[string]UserProvider `json:"providers"`
34+}
35+
36+// UserProvider groups models under a provider key.
37+type UserProvider struct {
38+ Models []UserModel `json:"models"`
39+}
40+
41+// UserModel is a single model entry in the user's models.json.
42+type UserModel struct {
43+ ID string `json:"id"`
44+ Name string `json:"name"`
45+ Reasoning bool `json:"reasoning"`
46+ ContextWindow int `json:"contextWindow"`
47+ MaxTokens int `json:"maxTokens"`
48+ PriceInput float64 `json:"priceInput"`
49+ PriceOutput float64 `json:"priceOutput"`
50+ PriceCacheRead float64 `json:"priceCacheRead"`
51+ PriceCacheWrite float64 `json:"priceCacheWrite"`
52+ Input []string `json:"input"` // informational only
53+ API string `json:"api"` // informational only
54+}
55+
56+// LoadUserModels reads a models.json file and returns the models
57+// converted to the internal Model type. Returns nil on any error
58+// (missing file, bad JSON, etc.) so the caller can treat it as
59+// optional without error handling.
60+func LoadUserModels(path string) []Model {
61+ data, err := os.ReadFile(path)
62+ if err != nil {
63+ return nil
64+ }
65+ var file UserModelsFile
66+ if err := json.Unmarshal(data, &file); err != nil {
67+ return nil
68+ }
69+
70+ var out []Model
71+ for providerName, prov := range file.Providers {
72+ // Normalize provider name: strip suffixes like "-codex" so
73+ // "openai-codex" maps to "openai".
74+ normalized := providerName
75+ switch providerName {
76+ case "openai-codex", "openai-responses":
77+ normalized = "openai"
78+ case "anthropic-messages":
79+ normalized = "anthropic"
80+ }
81+
82+ for _, um := range prov.Models {
83+ m := Model{
84+ Provider: normalized,
85+ ID: um.ID,
86+ DisplayName: um.Name,
87+ ContextWindow: um.ContextWindow,
88+ MaxOutput: um.MaxTokens,
89+ Reasoning: um.Reasoning,
90+ PriceInput: um.PriceInput,
91+ PriceOutput: um.PriceOutput,
92+ PriceCacheRead: um.PriceCacheRead,
93+ PriceCacheWrite: um.PriceCacheWrite,
94+ Source: "user",
95+ }
96+ if m.DisplayName == "" {
97+ m.DisplayName = m.ID
98+ }
99+ out = append(out, m)
100+ }
101+ }
102+ return out
103+}
104+
105+// SetUserModels merges user-defined models into the active catalog.
106+// User models take precedence over both the baked-in catalog and
107+// live-discovered models.
108+func SetUserModels(models []Model) {
109+ if len(models) == 0 {
110+ return
111+ }
112+ activeMu.Lock()
113+ defer activeMu.Unlock()
114+
115+ // Build index of current active models.
116+ byKey := func(p, id string) string { return p + "/" + id }
117+ index := make(map[string]int, len(active))
118+ for i, m := range active {
119+ index[byKey(m.Provider, m.ID)] = i
120+ }
121+
122+ for _, um := range models {
123+ k := byKey(um.Provider, um.ID)
124+ if idx, ok := index[k]; ok {
125+ // Override existing entry but keep prices from user if
126+ // they provided them, otherwise keep catalog prices.
127+ existing := active[idx]
128+ if um.PriceInput > 0 {
129+ existing.PriceInput = um.PriceInput
130+ }
131+ if um.PriceOutput > 0 {
132+ existing.PriceOutput = um.PriceOutput
133+ }
134+ if um.PriceCacheRead > 0 {
135+ existing.PriceCacheRead = um.PriceCacheRead
136+ }
137+ if um.PriceCacheWrite > 0 {
138+ existing.PriceCacheWrite = um.PriceCacheWrite
139+ }
140+ if um.DisplayName != "" {
141+ existing.DisplayName = um.DisplayName
142+ }
143+ if um.ContextWindow > 0 {
144+ existing.ContextWindow = um.ContextWindow
145+ }
146+ if um.MaxOutput > 0 {
147+ existing.MaxOutput = um.MaxOutput
148+ }
149+ existing.Reasoning = um.Reasoning
150+ existing.Source = "user"
151+ existing.Speculative = false
152+ active[idx] = existing
153+ } else {
154+ // New model not in catalog.
155+ active = append(active, um)
156+ }
157+ }
158+}

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