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