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}