mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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.
This commit is contained in:
parent
47c154950e
commit
b245be02e5
4 changed files with 199 additions and 0 deletions
28
README.md
28
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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
158
internal/provider/usermodels.go
Normal file
158
internal/provider/usermodels.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue