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 Models []UserModel `json:"models"`
43}
44
45// CustomProviderConfig holds runtime config for a user-defined provider
46// that isn't part of the built-in catalog.
47type CustomProviderConfig struct {
48 BaseURL string
49 API string // "openai", "openai-responses", or "anthropic"
50}
51
52var (
53 customProvidersMu sync.RWMutex
54 customProviders = map[string]CustomProviderConfig{}
55)
56
57// CustomProviders returns a snapshot of the user-defined providers loaded
58// from models.json. Callers cannot mutate provider-owned registry state.
59func CustomProviders() map[string]CustomProviderConfig {
60 customProvidersMu.RLock()
61 defer customProvidersMu.RUnlock()
62 return maps.Clone(customProviders)
63}
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 customProvidersMu.Lock()
138 defer customProvidersMu.Unlock()
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 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
171 api := strings.ToLower(strings.TrimSpace(prov.API))
172 if api == "" {
173 api = "openai"
174 }
175 // Normalize common aliases for the wire format.
176 switch api {
177 case "openai-completions", "openai-chat", "chat", "openai":
178 api = "openai"
179 case "openai-responses", "responses":
180 api = APIResponses
181 case "anthropic-messages", "messages", "anthropic":
182 api = "anthropic"
183 default:
184 warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown api %q; defaulting to openai", providerName, prov.API))
185 api = "openai"
186 }
187 customProviders[normalized] = CustomProviderConfig{
188 BaseURL: prov.BaseURL,
189 API: api,
190 }
191 }
192
193 for i, um := range prov.Models {
194 if um.ID == "" {
195 warnings = append(warnings, fmt.Sprintf("models.json: provider %q entry #%d has empty id; skipped", providerName, i))
196 continue
197 }
198 if um.ContextWindow < 0 || um.MaxTokens < 0 {
199 warnings = append(warnings, fmt.Sprintf("models.json: %s/%s has negative contextWindow/maxTokens; clamped to 0", normalized, um.ID))
200 if um.ContextWindow < 0 {
201 um.ContextWindow = 0
202 }
203 if um.MaxTokens < 0 {
204 um.MaxTokens = 0
205 }
206 }
207 levelMap, levelWarnings := normalizeReasoningLevelMap(um.ReasoningLevelMap, normalized+"/"+um.ID)
208 warnings = append(warnings, levelWarnings...)
209 if !um.Reasoning && len(levelMap) > 0 {
210 warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap ignored because reasoning is false", normalized+"/"+um.ID))
211 levelMap = nil
212 }
213
214 // Propagate provider-level BaseURL to models without their own.
215 modelBaseURL := um.BaseURL
216 if modelBaseURL == "" {
217 modelBaseURL = prov.BaseURL
218 }
219 modelAPI := ""
220 if cfg, ok := customProviders[normalized]; ok {
221 modelAPI = cfg.API
222 }
223 m := Model{
224 Provider: normalized,
225 ID: um.ID,
226 DisplayName: um.Name,
227 API: modelAPI,
228 ContextWindow: um.ContextWindow,
229 MaxOutput: um.MaxTokens,
230 Reasoning: um.Reasoning,
231 ReasoningLevelMap: levelMap,
232 PriceInput: um.PriceInput,
233 PriceOutput: um.PriceOutput,
234 PriceCacheRead: um.PriceCacheRead,
235 PriceCacheWrite: um.PriceCacheWrite,
236 BaseURL: modelBaseURL,
237 Source: "user",
238 }
239 if m.DisplayName == "" {
240 m.DisplayName = m.ID
241 }
242 out = append(out, m)
243 }
244 }
245 return out, warnings
246}
247
248// SetUserModels replaces the durable user-model overlay. User models take
249// precedence over the baked-in, live-discovered, and managed catalogs. Keeping
250// the overlay separate means a later live refresh cannot discard it.
251func SetUserModels(models []Model) {
252 activeMu.Lock()
253 defer activeMu.Unlock()
254 userModels = cloneModels(models)
255}