1package provider
2
3import (
4 "fmt"
5 "maps"
6 "sync"
7)
8
9// Deliberate working budget, not the models' advertised maximum context.
10const openAIContextWindowTarget = 500_000
11
12// ModelPriceTier describes the pricing that applies once the prompt reaches
13// InputTokens. Tiers are retained in model metadata so cost calculation can
14// select the highest applicable threshold.
15type ModelPriceTier struct {
16 InputTokens int
17 PriceInput float64
18 PriceOutput float64
19 PriceCacheRead float64
20 PriceCacheWrite float64
21}
22
23// Model describes a single LLM we know about.
24type Model struct {
25 Provider string // "anthropic" | "openai"
26 ID string // API id
27 DisplayName string
28 API string // wire API override for providers that support multiple protocols
29 ContextWindow int
30 MaxOutput int
31 Reasoning bool // supports reasoning
32 ReasoningLevelMap map[string]string // optional level overrides; empty values remove a level
33 // ReasoningEffortMap translates normalized zut reasoning levels to the
34 // provider's exact effort values when a model-specific catalog supplies
35 // them (for example, minimum -> minimal).
36 ReasoningEffortMap map[string]string
37
38 // AdaptiveThinking marks Anthropic models that only support the
39 // adaptive thinking mode (Opus 4.7+). These reject explicit
40 // thinking budgets (thinking:{type:"enabled",budget_tokens:N} -> 400)
41 // and also reject non-default sampling params (temperature/top_p/
42 // top_k). The Anthropic client sends thinking:{type:"adaptive"} plus
43 // output_config.effort and omits temperature for these models.
44 AdaptiveThinking bool
45
46 // AdaptiveThinkingCompat marks Anthropic-compatible models that require
47 // thinking:{type:"adaptive"} but still accept sampling parameters and do
48 // not support Anthropic's output_config.effort extension.
49 AdaptiveThinkingCompat bool
50
51 // Prices are USD per 1M tokens. Price*Above fields apply to all token
52 // classes when the total prompt size reaches PriceTierInputTokens.
53 PriceInput float64
54 PriceOutput float64
55 PriceCacheRead float64
56 PriceCacheWrite float64
57 // PriceTiers contains all context-pricing thresholds, ordered by
58 // InputTokens. The legacy Price*Above fields retain the first tier for
59 // compatibility with callers that only understand one threshold.
60 PriceTiers []ModelPriceTier
61 PriceTierInputTokens int
62 PriceInputAbove float64
63 PriceOutputAbove float64
64 PriceCacheReadAbove float64
65 PriceCacheWriteAbove float64
66
67 // Speculative marks models whose ids are known from the upstream
68 // vendor's CLI but not yet live on their public API. They'll 404
69 // today but start working the moment the provider flips the switch.
70 Speculative bool
71
72 // BaseURL overrides the provider's default API endpoint for this
73 // model. Optional; when empty the provider's default (or the
74 // --base-url flag) is used. Useful for local models served by
75 // ollama, vLLM, LM Studio, etc.
76 BaseURL string
77
78 // Source is where this model entry came from: "catalog" (baked in),
79 // "live" (discovered via /v1/models), or "cache" (loaded from the
80 // on-disk cache). Informational.
81 Source string
82}
83
84// Catalog is the hardcoded, read-only list of supported models.
85// Prices are USD per 1M tokens. The list is curated to what zut's
86// clients (Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses)
87// can actually talk to; models that are only reachable through an unsupported
88// provider-specific protocol are omitted. OpenCode Go is populated
89// separately by runtime discovery from its provider API and models.dev.
90var Catalog = []Model{
91 // ---- Anthropic / Claude 4.x ----
92 {
93 Provider: "anthropic", ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5 (latest)",
94 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
95 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
96 },
97 {
98 Provider: "anthropic", ID: "claude-opus-4-1", DisplayName: "Claude Opus 4.1 (latest)",
99 ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
100 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
101 },
102 {
103 Provider: "anthropic", ID: "claude-opus-4-0", DisplayName: "Claude Opus 4 (latest)",
104 ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
105 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
106 },
107 {
108 Provider: "anthropic", ID: "claude-sonnet-4-0", DisplayName: "Claude Sonnet 4 (latest)",
109 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
110 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
111 },
112 {
113 Provider: "anthropic", ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5 (latest)",
114 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
115 PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
116 },
117
118 // ---- Anthropic / Claude 3.x (legacy) ----
119 {
120 Provider: "anthropic", ID: "claude-3-7-sonnet-20250219", DisplayName: "Claude Sonnet 3.7",
121 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
122 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
123 },
124 {
125 Provider: "anthropic", ID: "claude-3-5-sonnet-20241022", DisplayName: "Claude Sonnet 3.5 v2",
126 ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
127 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
128 },
129 {
130 Provider: "anthropic", ID: "claude-3-5-haiku-latest", DisplayName: "Claude Haiku 3.5 (latest)",
131 ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
132 PriceInput: 0.8, PriceOutput: 4, PriceCacheRead: 0.08, PriceCacheWrite: 1,
133 },
134 {
135 Provider: "anthropic", ID: "claude-3-opus-20240229", DisplayName: "Claude Opus 3",
136 ContextWindow: 200000, MaxOutput: 4096, Reasoning: false,
137 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
138 },
139
140 // ---- DeepSeek ----
141 // The current public DeepSeek API exposes the V4 family on
142 // api.deepseek.com/v1. Pro is the flagship reasoning model;
143 // Flash is the cheaper/faster sibling. Both accept image inputs
144 // (multimodal parts: image_url) in addition to text.
145 {
146 Provider: "deepseek", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro",
147 ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
148 PriceInput: 0.435, PriceOutput: 0.87, PriceCacheRead: 0.003625,
149 BaseURL: "https://api.deepseek.com",
150 },
151 {
152 Provider: "deepseek", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash",
153 ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
154 PriceInput: 0.14, PriceOutput: 0.28, PriceCacheRead: 0.0028,
155 BaseURL: "https://api.deepseek.com",
156 },
157
158 // ---- Kimi / Kimi Code ----
159 // Anthropic-messages on https://api.kimi.com/coding (no /v1 suffix;
160 // the Anthropic client appends /v1/messages itself).
161 {
162 Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi For Coding",
163 ContextWindow: 262144, MaxOutput: 32768, Reasoning: true,
164 PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0,
165 BaseURL: "https://api.kimi.com/coding",
166 },
167
168 // ---- OpenAI / GPT-5 family ----
169 {
170 Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
171 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
172 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
173 },
174 {
175 Provider: "openai", ID: "gpt-5-mini", DisplayName: "GPT-5 Mini",
176 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
177 PriceInput: 0.25, PriceOutput: 2, PriceCacheRead: 0.025,
178 },
179 {
180 Provider: "openai", ID: "gpt-5-nano", DisplayName: "GPT-5 Nano",
181 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
182 PriceInput: 0.05, PriceOutput: 0.4, PriceCacheRead: 0.005,
183 },
184
185 // ---- OpenAI / GPT-4.1 family ----
186 {
187 Provider: "openai", ID: "gpt-4.1", DisplayName: "GPT-4.1",
188 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
189 PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
190 },
191 {
192 Provider: "openai", ID: "gpt-4.1-mini", DisplayName: "GPT-4.1 mini",
193 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
194 PriceInput: 0.4, PriceOutput: 1.6, PriceCacheRead: 0.1,
195 },
196 {
197 Provider: "openai", ID: "gpt-4.1-nano", DisplayName: "GPT-4.1 nano",
198 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
199 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.03,
200 },
201
202 // ---- OpenAI / GPT-4o family ----
203 {
204 Provider: "openai", ID: "gpt-4o", DisplayName: "GPT-4o",
205 ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
206 PriceInput: 2.5, PriceOutput: 10, PriceCacheRead: 1.25,
207 },
208 {
209 Provider: "openai", ID: "gpt-4o-mini", DisplayName: "GPT-4o mini",
210 ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
211 PriceInput: 0.15, PriceOutput: 0.6, PriceCacheRead: 0.08,
212 },
213
214 // ---- OpenAI / reasoning models ----
215 {
216 Provider: "openai", ID: "o4-mini", DisplayName: "o4-mini",
217 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
218 PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.28,
219 },
220 {
221 Provider: "openai", ID: "o3", DisplayName: "o3",
222 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
223 PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
224 },
225 {
226 Provider: "openai", ID: "o3-mini", DisplayName: "o3-mini",
227 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
228 PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.55,
229 },
230 {
231 Provider: "openai", ID: "o1", DisplayName: "o1",
232 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
233 PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5,
234 },
235
236 // ---- Google / Gemini ----
237 {
238 Provider: "google", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro",
239 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
240 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
241 },
242 {
243 Provider: "google", ID: "gemini-2.5-flash", DisplayName: "Gemini 2.5 Flash",
244 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
245 PriceInput: 0.3, PriceOutput: 2.5, PriceCacheRead: 0.03,
246 },
247 {
248 Provider: "google", ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash-Lite",
249 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
250 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.01,
251 },
252 {
253 Provider: "google", ID: "gemini-2.0-flash", DisplayName: "Gemini 2.0 Flash",
254 ContextWindow: 1048576, MaxOutput: 8192, Reasoning: false,
255 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.025,
256 },
257 {
258 Provider: "google", ID: "gemini-2.0-flash-lite", DisplayName: "Gemini 2.0 Flash-Lite",
259 ContextWindow: 1048576, MaxOutput: 8192, Reasoning: false,
260 PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0,
261 },
262
263 // ---- OpenRouter ----
264 // Seed entry only: the default model returned by defaultModelForProvider
265 // so it resolves offline. The full OpenRouter catalog is discovered live
266 // (DiscoverOpenRouter) and overlays this on refresh.
267 {
268 Provider: "openrouter", ID: "anthropic/claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5 (OpenRouter)",
269 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
270 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
271 BaseURL: openrouterDefaultBaseURL,
272 },
273 {
274 Provider: "openrouter", ID: "anthropic/claude-sonnet-5", DisplayName: "Claude Sonnet 5 (OpenRouter)",
275 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
276 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
277 BaseURL: openrouterDefaultBaseURL,
278 },
279
280 // ---- Speculative: Anthropic ----
281 {
282 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
283 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
284 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
285 Speculative: true,
286 },
287 {
288 Provider: "anthropic", ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6",
289 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
290 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
291 Speculative: true,
292 },
293 {
294 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
295 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
296 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
297 Speculative: true,
298 },
299 {
300 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
301 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
302 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
303 Speculative: true,
304 },
305 {
306 Provider: "anthropic", ID: "claude-opus-5", DisplayName: "Claude Opus 5",
307 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
308 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
309 },
310 {
311 Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
312 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
313 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
314 Speculative: true,
315 },
316 {
317 Provider: "anthropic", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
318 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
319 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
320 },
321 {
322 Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
323 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
324 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
325 Speculative: true,
326 },
327
328 // ---- Speculative: OpenAI ----
329 // Public OpenAI API route. The ChatGPT/Codex subscription route is
330 // represented separately below as provider "openai-codex".
331 {
332 Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
333 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
334 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
335 Speculative: true,
336 },
337 {
338 Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
339 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
340 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
341 Speculative: true,
342 },
343 {
344 Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
345 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
346 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
347 Speculative: true,
348 },
349 {
350 Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
351 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
352 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
353 Speculative: true,
354 },
355 {
356 Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
357 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
358 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
359 Speculative: true,
360 },
361 {
362 Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
363 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
364 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
365 Speculative: true,
366 },
367 {
368 Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
369 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
370 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
371 Speculative: true,
372 },
373 {
374 Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
375 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
376 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
377 Speculative: true,
378 },
379 {
380 Provider: "openai", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses,
381 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
382 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5,
383 PriceTierInputTokens: 272000,
384 PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25,
385 },
386 // ---- OpenAI Codex / ChatGPT subscription backend ----
387 // Same model ids as the OpenAI family, but routed through the
388 // ChatGPT Codex OAuth backend rather than api.openai.com.
389 {
390 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
391 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
392 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
393 Speculative: true,
394 },
395 {
396 Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
397 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
398 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
399 Speculative: true,
400 },
401 {
402 Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
403 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
404 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
405 Speculative: true,
406 },
407 {
408 Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
409 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
410 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
411 Speculative: true,
412 },
413 {
414 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
415 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
416 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
417 },
418 {
419 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
420 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
421 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
422 },
423 {
424 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
425 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
426 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
427 },
428 {
429 Provider: "openai-codex", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra",
430 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
431 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5,
432 PriceTierInputTokens: 272000,
433 PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25,
434 },
435}
436
437// DefaultModel is used when the user does not specify one.
438var DefaultModel = Catalog[0] // claude-sonnet-4-5
439
440// ----- active (merged) catalog -----
441//
442// Callers should use Active() / FindModel / ModelsForProvider for
443// lookups. They return the baked-in Catalog merged with any live
444// models loaded via SetLiveModels.
445
446var (
447 activeMu sync.RWMutex
448 active []Model // live overlay merged in via SetLiveModels; nil = none yet
449 activeSet bool // true once SetLiveModels has run (even with empty live)
450 authoritativeProviderSet map[string]struct{}
451 managedModels []Model // ephemeral models exposed by local model managers
452 userModels []Model // highest-precedence models loaded from models.json
453)
454
455// CatalogSnapshot captures the mutable catalog overlays so a caller can
456// restore provider state after temporarily installing test or runtime data.
457// Use SnapshotCatalog and RestoreCatalog instead of replacing the live
458// overlay with nil, which can discard another caller's active catalog.
459type CatalogSnapshot struct {
460 active []Model
461 activeSet bool
462 authoritativeProviderSet map[string]struct{}
463 managedModels []Model
464 userModels []Model
465}
466
467// SnapshotCatalog returns a deep copy of the mutable provider catalog state.
468func SnapshotCatalog() CatalogSnapshot {
469 activeMu.RLock()
470 defer activeMu.RUnlock()
471
472 snapshot := CatalogSnapshot{
473 activeSet: activeSet,
474 authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
475 }
476 if active != nil {
477 snapshot.active = cloneModels(active)
478 }
479 if managedModels != nil {
480 snapshot.managedModels = cloneModels(managedModels)
481 }
482 if userModels != nil {
483 snapshot.userModels = cloneModels(userModels)
484 }
485 return snapshot
486}
487
488// RestoreCatalog restores a snapshot returned by SnapshotCatalog.
489func RestoreCatalog(snapshot CatalogSnapshot) {
490 activeMu.Lock()
491 defer activeMu.Unlock()
492
493 if snapshot.active != nil {
494 active = cloneModels(snapshot.active)
495 } else {
496 active = nil
497 }
498 activeSet = snapshot.activeSet
499 authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
500 if snapshot.managedModels != nil {
501 managedModels = cloneModels(snapshot.managedModels)
502 } else {
503 managedModels = nil
504 }
505 if snapshot.userModels != nil {
506 userModels = cloneModels(snapshot.userModels)
507 } else {
508 userModels = nil
509 }
510}
511
512func cloneModel(model Model) Model {
513 model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
514 model.ReasoningEffortMap = maps.Clone(model.ReasoningEffortMap)
515 if model.PriceTiers != nil {
516 model.PriceTiers = append([]ModelPriceTier(nil), model.PriceTiers...)
517 }
518 return model
519}
520
521func cloneModels(models []Model) []Model {
522 out := make([]Model, len(models))
523 for i, model := range models {
524 out[i] = cloneModel(model)
525 }
526 return out
527}
528
529func mergeUserModels(active, users []Model) []Model {
530 if len(users) == 0 {
531 return active
532 }
533 byKey := func(provider, id string) string { return provider + "\x00" + id }
534 index := make(map[string]int, len(active))
535 for i, model := range active {
536 index[byKey(model.Provider, model.ID)] = i
537 }
538 for _, user := range users {
539 user = cloneModel(user)
540 key := byKey(user.Provider, user.ID)
541 if idx, ok := index[key]; ok {
542 existing := active[idx]
543 // Preserve catalog values when an override leaves an optional
544 // field at its zero value, matching models.json semantics.
545 // A positive price override replaces tiered pricing too;
546 // otherwise ComputeCost would retain discovered tier rates.
547 if user.PriceInput > 0 || user.PriceOutput > 0 || user.PriceCacheRead > 0 || user.PriceCacheWrite > 0 {
548 existing.PriceTiers = nil
549 existing.PriceTierInputTokens = 0
550 existing.PriceInputAbove, existing.PriceOutputAbove = 0, 0
551 existing.PriceCacheReadAbove, existing.PriceCacheWriteAbove = 0, 0
552 }
553 if user.PriceInput > 0 {
554 existing.PriceInput = user.PriceInput
555 }
556 if user.PriceOutput > 0 {
557 existing.PriceOutput = user.PriceOutput
558 }
559 if user.PriceCacheRead > 0 {
560 existing.PriceCacheRead = user.PriceCacheRead
561 }
562 if user.PriceCacheWrite > 0 {
563 existing.PriceCacheWrite = user.PriceCacheWrite
564 }
565 if user.DisplayName != "" {
566 existing.DisplayName = user.DisplayName
567 }
568 if user.ContextWindow > 0 {
569 existing.ContextWindow = user.ContextWindow
570 }
571 if user.MaxOutput > 0 {
572 existing.MaxOutput = user.MaxOutput
573 }
574 existing.Reasoning = user.Reasoning
575 if user.ReasoningLevelMap != nil {
576 existing.ReasoningLevelMap = user.ReasoningLevelMap
577 }
578 if user.ReasoningEffortMap != nil {
579 existing.ReasoningEffortMap = user.ReasoningEffortMap
580 }
581 if user.API != "" {
582 existing.API = user.API
583 }
584 if user.BaseURL != "" {
585 existing.BaseURL = user.BaseURL
586 }
587 existing.Source = "user"
588 existing.Speculative = false
589 active[idx] = existing
590 continue
591 }
592 index[key] = len(active)
593 active = append(active, user)
594 }
595 return active
596}
597
598// SetLiveModels replaces the "live" overlay used by the active catalog.
599// Typically called after a successful /v1/models discovery or on load
600// from the on-disk cache.
601func SetLiveModels(live []Model) {
602 SetLiveModelsForProviders(live, nil)
603}
604
605// SetLiveModelsForProviders replaces the live overlay, treating the listed
606// providers' live catalogs as authoritative. For those providers, static
607// entries omitted by discovery are removed from the active catalog.
608func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
609 activeMu.Lock()
610 defer activeMu.Unlock()
611 activeSet = true
612 authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders))
613 for _, name := range authoritativeProviders {
614 authoritativeProviderSet[name] = struct{}{}
615 }
616 if len(live) == 0 && len(authoritativeProviders) == 0 {
617 active = nil
618 return
619 }
620 active = MergeCatalogForProviders(live, authoritativeProviders)
621}
622
623// ClearLiveModelsForProvider removes a provider's live overlay without
624// disturbing other providers. User models remain separate and continue to
625// be applied by Active.
626func ClearLiveModelsForProvider(name string) {
627 activeMu.Lock()
628 defer activeMu.Unlock()
629 delete(authoritativeProviderSet, name)
630 if !activeSet || len(active) == 0 {
631 return
632 }
633 out := active[:0]
634 for _, model := range active {
635 if model.Provider != name {
636 out = append(out, model)
637 }
638 }
639 active = out
640}
641
642// Active returns the current merged catalog.
643//
644// When no live overlay has been set it returns the fully-assembled
645// static Catalog. Reading Catalog here (rather than capturing it into
646// a package-level var initializer) is load-bearing: the extended
647// catalog in catalog_builtin.go / extra_models.go is appended from
648// init() functions, which run AFTER package-level var initializers.
649// Snapshotting Catalog at var-init time would freeze the picker to the
650// curated seed list and drop every extra provider (openrouter, groq,
651// xai, ...). Deferring the read to call time avoids that ordering trap.
652func Active() []Model {
653 activeMu.RLock()
654 defer activeMu.RUnlock()
655 src := active
656 if !activeSet || src == nil {
657 src = Catalog
658 }
659 out := cloneModels(src)
660 if len(managedModels) > 0 {
661 index := make(map[string]int, len(out))
662 for i, model := range out {
663 index[model.Provider+"\x00"+model.ID] = i
664 }
665 for _, model := range managedModels {
666 model = cloneModel(model)
667 key := model.Provider + "\x00" + model.ID
668 if i, ok := index[key]; ok {
669 out[i] = model
670 continue
671 }
672 index[key] = len(out)
673 out = append(out, model)
674 }
675 }
676 // User models are a durable, highest-precedence overlay. Apply them
677 // after the live and managed overlays so a catalog refresh cannot
678 // discard overrides or user-only entries.
679 return mergeUserModels(out, userModels)
680}
681
682// SetManagedModels replaces the ephemeral catalog entries supplied by local
683// model managers. Unlike SetLiveModels, this does not disturb provider model
684// discovery or its on-disk cache.
685func SetManagedModels(models []Model) {
686 activeMu.Lock()
687 defer activeMu.Unlock()
688 managedModels = append([]Model(nil), models...)
689}
690
691// FindModel returns a Model by id, optionally constrained by provider.
692// If provider is empty, the first matching id is returned. Looks up
693// against the merged active catalog.
694func FindModel(provider, id string) (Model, error) {
695 for _, m := range Active() {
696 if m.ID == id && (provider == "" || m.Provider == provider) {
697 return m, nil
698 }
699 }
700 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
701}
702
703// IsProviderCatalogAuthoritative reports whether a successful live catalog
704// has declared the provider's available model IDs authoritative.
705func IsProviderCatalogAuthoritative(provider string) bool {
706 activeMu.RLock()
707 defer activeMu.RUnlock()
708 _, ok := authoritativeProviderSet[provider]
709 return ok
710}
711
712// AcceptsUnlistedModels reports whether a provider may be queried with an
713// ID that is not in the local catalog. OpenCode Go permits this only before
714// a successful authoritative live catalog has been loaded.
715func AcceptsUnlistedModels(provider string) bool {
716 return provider == ProviderOpenCodeGo && !IsProviderCatalogAuthoritative(provider)
717}
718
719// ModelsForProvider returns all models for the given provider, from the
720// merged active catalog.
721func ModelsForProvider(provider string) []Model {
722 var out []Model
723 for _, m := range Active() {
724 if m.Provider == provider {
725 out = append(out, m)
726 }
727 }
728 return out
729}
730
731// ComputeCost returns the USD cost for the given usage on model m.
732func ComputeCost(m Model, u Usage) float64 {
733 inputPrice := m.PriceInput
734 outputPrice := m.PriceOutput
735 cacheReadPrice := m.PriceCacheRead
736 cacheWritePrice := m.PriceCacheWrite
737 promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
738 if len(m.PriceTiers) > 0 {
739 selected := -1
740 for i, tier := range m.PriceTiers {
741 if tier.InputTokens <= promptTokens && (selected < 0 || tier.InputTokens > m.PriceTiers[selected].InputTokens) {
742 selected = i
743 }
744 }
745 if selected >= 0 {
746 tier := m.PriceTiers[selected]
747 inputPrice = tier.PriceInput
748 outputPrice = tier.PriceOutput
749 cacheReadPrice = tier.PriceCacheRead
750 cacheWritePrice = tier.PriceCacheWrite
751 }
752 } else if m.PriceTierInputTokens > 0 && promptTokens >= m.PriceTierInputTokens {
753 // Keep the legacy single-tier fields inclusive at the threshold.
754 inputPrice = m.PriceInputAbove
755 outputPrice = m.PriceOutputAbove
756 cacheReadPrice = m.PriceCacheReadAbove
757 cacheWritePrice = m.PriceCacheWriteAbove
758 }
759
760 const per = 1_000_000.0
761 return float64(u.InputTokens)*inputPrice/per +
762 float64(u.OutputTokens)*outputPrice/per +
763 float64(u.CacheReadTokens)*cacheReadPrice/per +
764 float64(u.CacheWriteTokens)*cacheWritePrice/per
765}