main 4509ec7

usermodels.go RawHistoryFolder

1package provider
2
3import (
4	"encoding/json"
5	"fmt"
6	"maps"
7	"os"
8	"strings"
9	"sync"
10)
11
12// UserModelsFile is the JSON format for user-defined models.
13// Place a models.json in $ZUT_HOME to add models that aren't in the
14// baked-in catalog or to override catalog entries. Custom providers
15// (not in the built-in set) may specify a baseUrl and api format at
16// the provider level:
17//
18//	{
19//	  "providers": {
20//	    "my-company": {
21//	      "baseUrl": "https://llm.mycompany.com/v1",
22//	      "api": "openai",
23//	      "models": [
24//	        {
25//	          "id": "company-llm-v2",
26//	          "name": "Company LLM v2",
27//	          "contextWindow": 128000,
28//	          "maxTokens": 32000
29//	        }
30//	      ]
31//	    }
32//	  }
33//	}
34type UserModelsFile struct {
35	Providers map[string]UserProvider `json:"providers"`
36}
37
38// UserProvider groups models under a provider key.
39type UserProvider struct {
40	BaseURL string `json:"baseUrl,omitempty"`
41	API     string `json:"api,omitempty"`  // "openai" (default), "openai-responses", or "anthropic"
42	Auth    string `json:"auth,omitempty"` // "none" disables credentials, including stored keys
43	// Discover enables live model listing from the provider-level
44	// baseUrl's /models endpoint. Off by default so existing custom
45	// providers never gain unexpected network traffic.
46	Discover bool        `json:"discover,omitempty"`
47	Models   []UserModel `json:"models"`
48}
49
50// CustomProviderConfig holds runtime config for a user-defined provider
51// that isn't part of the built-in catalog.
52type CustomProviderConfig struct {
53	BaseURL string
54	API     string // "openai", "openai-responses", or "anthropic"
55	NoAuth  bool   // explicitly configured for keyless access
56	// Discover reports whether live model discovery is enabled. It is
57	// only true when a provider-level BaseURL is present.
58	Discover bool
59}
60
61var (
62	customProvidersMu sync.RWMutex
63	customProviders   = map[string]CustomProviderConfig{}
64)
65
66// CustomProviders returns a snapshot of the user-defined providers loaded
67// from models.json. Callers cannot mutate provider-owned registry state.
68func CustomProviders() map[string]CustomProviderConfig {
69	customProvidersMu.RLock()
70	defer customProvidersMu.RUnlock()
71	return maps.Clone(customProviders)
72}
73
74// UserModel is a single model entry in the user's models.json.
75type UserModel struct {
76	ID                string            `json:"id"`
77	Name              string            `json:"name"`
78	Reasoning         bool              `json:"reasoning"`
79	ReasoningLevelMap map[string]string `json:"reasoningLevelMap,omitempty"`
80	ContextWindow     int               `json:"contextWindow"`
81	MaxTokens         int               `json:"maxTokens"`
82	PriceInput        float64           `json:"priceInput"`
83	PriceOutput       float64           `json:"priceOutput"`
84	PriceCacheRead    float64           `json:"priceCacheRead"`
85	PriceCacheWrite   float64           `json:"priceCacheWrite"`
86	BaseURL           string            `json:"baseUrl,omitempty"`
87	Input             []string          `json:"input"` // informational only
88	API               string            `json:"api"`   // informational only
89}
90
91func normalizeReasoningLevelMap(levelMap map[string]string, modelRef string) (map[string]string, []string) {
92	if levelMap == nil {
93		return nil, nil
94	}
95	normalized := make(map[string]string, len(levelMap))
96	var warnings []string
97	for rawLevel, rawTarget := range levelMap {
98		level := NormalizeReasoning(rawLevel)
99		if reasoningLevelRank(level) == 0 {
100			warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap has unknown level %q; entry ignored", modelRef, rawLevel))
101			continue
102		}
103		target := NormalizeReasoning(rawTarget)
104		if rawTarget != "" && target == "" {
105			// Explicit off aliases remove the level just like an empty value.
106			normalized[level] = ""
107			continue
108		}
109		if target != "" && reasoningLevelRank(target) == 0 {
110			warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap has unknown target %q; entry ignored", modelRef, rawTarget))
111			continue
112		}
113		normalized[level] = target
114	}
115	return normalized, warnings
116}
117
118// LoadUserModels reads a models.json file and returns the models
119// converted to the internal Model type. Returns nil on any error
120// (missing file, bad JSON, etc.) so the caller can treat it as
121// optional without error handling.
122func LoadUserModels(path string) []Model {
123	models, _ := LoadUserModelsWithWarnings(path)
124	return models
125}
126
127// LoadUserModelsWithWarnings is like LoadUserModels but also returns
128// human-readable warnings about every recoverable issue it found in
129// the file (unknown provider id, empty model id, malformed JSON for a
130// single provider block, etc.). The caller is responsible for
131// surfacing the warnings; the file is never rejected wholesale unless
132// the top-level JSON itself fails to parse.
133func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
134	var warnings []string
135	data, err := os.ReadFile(path)
136	if err != nil {
137		return nil, nil
138	}
139	var file UserModelsFile
140	if err := json.Unmarshal(data, &file); err != nil {
141		warnings = append(warnings, fmt.Sprintf("models.json: parse error: %v (file ignored)", err))
142		return nil, warnings
143	}
144
145	var out []Model
146	customProvidersMu.Lock()
147	defer customProvidersMu.Unlock()
148	// Reset custom providers on each load so removed entries don't linger.
149	customProviders = map[string]CustomProviderConfig{}
150	for providerName, prov := range file.Providers {
151		if providerName == "" {
152			warnings = append(warnings, "models.json: empty provider key skipped")
153			continue
154		}
155		// Normalize legacy transport aliases to their provider names.
156		normalized := providerName
157		switch providerName {
158		case "openai-responses":
159			normalized = "openai"
160		case "anthropic-messages":
161			normalized = "anthropic"
162		case "moonshot", "moonshot-ai", "kimi-code":
163			normalized = "kimi"
164		case "deepseek-chat", "deepseek-ai":
165			normalized = "deepseek"
166		}
167
168		// Register custom providers that carry endpoint metadata either at
169		// the provider level or on any model. Model-level baseUrl-only
170		// configs still need a custom provider entry so Resolve/NewClient
171		// accept the provider name and choose a wire format.
172		hasModelBaseURL := false
173		for _, um := range prov.Models {
174			if um.BaseURL != "" {
175				hasModelBaseURL = true
176				break
177			}
178		}
179		// Discovery needs one endpoint to list from, so it is tied to the
180		// provider-level baseUrl rather than per-model overrides.
181		discover := prov.Discover
182		if discover && strings.TrimSpace(prov.BaseURL) == "" {
183			warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))
184			discover = false
185		}
186		if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL {
187			warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName))
188		}
189		if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
190			auth := strings.ToLower(strings.TrimSpace(prov.Auth))
191			if auth != "" && auth != "none" {
192				warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth))
193			}
194			if prov.BaseURL == "" && !hasModelBaseURL {
195				auth = ""
196			}
197			if auth == "none" && isBuiltinProviderID(normalized) {
198				// Built-in providers keep their normal credential flow; a
199				// models.json override must not weaken it.
200				warnings = append(warnings, fmt.Sprintf("models.json: provider %q is a built-in provider; auth \"none\" ignored", providerName))
201				auth = ""
202			}
203			api := strings.ToLower(strings.TrimSpace(prov.API))
204			if api == "" {
205				api = "openai"
206			}
207			// Normalize common aliases for the wire format.
208			switch api {
209			case "openai-completions", "openai-chat", "chat", "openai":
210				api = "openai"
211			case "openai-responses", "responses":
212				api = APIResponses
213			case "anthropic-messages", "messages", "anthropic":
214				api = "anthropic"
215			default:
216				warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown api %q; defaulting to openai", providerName, prov.API))
217				api = "openai"
218			}
219			customProviders[normalized] = CustomProviderConfig{
220				BaseURL:  prov.BaseURL,
221				API:      api,
222				NoAuth:   auth == "none",
223				Discover: discover,
224			}
225		}
226
227		for i, um := range prov.Models {
228			if um.ID == "" {
229				warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))
230				continue
231			}
232			if um.ContextWindow < 0 || um.MaxTokens < 0 {
233				warnings = append(warnings, fmt.Sprintf("models.json: %s/%s has negative contextWindow/maxTokens; clamped to 0", normalized, um.ID))
234				if um.ContextWindow < 0 {
235					um.ContextWindow = 0
236				}
237				if um.MaxTokens < 0 {
238					um.MaxTokens = 0
239				}
240			}
241			levelMap, levelWarnings := normalizeReasoningLevelMap(um.ReasoningLevelMap, normalized+"/"+um.ID)
242			warnings = append(warnings, levelWarnings...)
243			if !um.Reasoning && len(levelMap) > 0 {
244				warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap ignored because reasoning is false", normalized+"/"+um.ID))
245				levelMap = nil
246			}
247
248			// Propagate provider-level BaseURL to models without their own.
249			modelBaseURL := um.BaseURL
250			if modelBaseURL == "" {
251				modelBaseURL = prov.BaseURL
252			}
253			modelAPI := ""
254			if cfg, ok := customProviders[normalized]; ok && (prov.API != "" || !isBuiltinProviderID(normalized)) {
255				// A built-in provider without an explicit api keeps its own
256				// protocol routing; the openai default is for custom providers.
257				modelAPI = cfg.API
258			}
259			m := Model{
260				Provider:          normalized,
261				ID:                um.ID,
262				DisplayName:       um.Name,
263				API:               modelAPI,
264				ContextWindow:     um.ContextWindow,
265				MaxOutput:         um.MaxTokens,
266				Reasoning:         um.Reasoning,
267				ReasoningLevelMap: levelMap,
268				PriceInput:        um.PriceInput,
269				PriceOutput:       um.PriceOutput,
270				PriceCacheRead:    um.PriceCacheRead,
271				PriceCacheWrite:   um.PriceCacheWrite,
272				BaseURL:           modelBaseURL,
273				Source:            "user",
274			}
275			if m.DisplayName == "" {
276				m.DisplayName = m.ID
277			}
278			out = append(out, m)
279		}
280	}
281	return out, warnings
282}
283
284// SetUserModels replaces the durable user-model overlay. User models take
285// precedence over the baked-in, live-discovered, and managed catalogs. Keeping
286// the overlay separate means a later live refresh cannot discard it.
287func SetUserModels(models []Model) {
288	activeMu.Lock()
289	defer activeMu.Unlock()
290	userModels = cloneModels(models)
291}

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