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