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-opus-5-5", DisplayName: "Claude Opus 5.5",
312 ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
313 PriceInput: 4, PriceOutput: 20, PriceCacheRead: 0.2, PriceCacheWrite: 5,
314 },
315 {
316 Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
317 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
318 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
319 Speculative: true,
320 },
321 {
322 Provider: "anthropic", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
323 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
324 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
325 },
326 {
327 Provider: "anthropic", ID: "claude-sonnet-5-5", DisplayName: "Claude Sonnet 5.5",
328 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
329 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
330 },
331 {
332 Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
333 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
334 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
335 Speculative: true,
336 },
337 {
338 Provider: "anthropic", ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1",
339 ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
340 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5,
341 },
342
343 // ---- Speculative: OpenAI ----
344 // Public OpenAI API route. The ChatGPT/Codex subscription route is
345 // represented separately below as provider "openai-codex".
346 {
347 Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
348 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
349 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
350 Speculative: true,
351 },
352 {
353 Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
354 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
355 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
356 Speculative: true,
357 },
358 {
359 Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
360 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
361 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
362 Speculative: true,
363 },
364 {
365 Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
366 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
367 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
368 Speculative: true,
369 },
370 {
371 Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
372 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
373 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
374 Speculative: true,
375 },
376 {
377 Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
378 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
379 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
380 Speculative: true,
381 },
382 {
383 Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
384 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
385 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
386 Speculative: true,
387 },
388 {
389 Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
390 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
391 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
392 Speculative: true,
393 },
394 {
395 Provider: "openai", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses,
396 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
397 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5,
398 PriceTierInputTokens: 272000,
399 PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25,
400 },
401 {
402 Provider: "openai", ID: "gpt-6.1-sol", DisplayName: "GPT-6.1 Sol", API: APIResponses,
403 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
404 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.1, PriceCacheWrite: 2.5,
405 PriceTierInputTokens: 272000,
406 PriceInputAbove: 4, PriceOutputAbove: 15, PriceCacheReadAbove: 0.2, PriceCacheWriteAbove: 5,
407 },
408 // ---- OpenAI Codex / ChatGPT subscription backend ----
409 // Same model ids as the OpenAI family, but routed through the
410 // ChatGPT Codex OAuth backend rather than api.openai.com.
411 {
412 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
413 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
414 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
415 Speculative: true,
416 },
417 {
418 Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
419 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
420 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
421 Speculative: true,
422 },
423 {
424 Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
425 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
426 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
427 Speculative: true,
428 },
429 {
430 Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
431 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
432 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
433 Speculative: true,
434 },
435 {
436 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
437 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
438 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
439 },
440 {
441 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
442 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
443 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
444 },
445 {
446 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
447 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
448 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
449 },
450 {
451 Provider: "openai-codex", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra",
452 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
453 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5,
454 PriceTierInputTokens: 272000,
455 PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25,
456 },
457 {
458 Provider: "openai-codex", ID: "gpt-6-sol", DisplayName: "GPT-6 Sol",
459 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
460 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
461 PriceTierInputTokens: 272000,
462 PriceInputAbove: 4, PriceOutputAbove: 15, PriceCacheReadAbove: 0.4, PriceCacheWriteAbove: 5,
463 },
464 {
465 Provider: "openai-codex", ID: "gpt-6.1-sol", DisplayName: "GPT-6.1 Sol",
466 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
467 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.1, PriceCacheWrite: 2.5,
468 PriceTierInputTokens: 272000,
469 PriceInputAbove: 4, PriceOutputAbove: 15, PriceCacheReadAbove: 0.2, PriceCacheWriteAbove: 5,
470 },
471 {
472 Provider: "openai-codex", ID: "gpt-6-luna", DisplayName: "GPT-6 Luna",
473 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
474 PriceInput: 0.1, PriceOutput: 0.5, PriceCacheRead: 0.01, PriceCacheWrite: 0.125,
475 PriceTierInputTokens: 272000,
476 PriceInputAbove: 0.2, PriceOutputAbove: 0.75, PriceCacheReadAbove: 0.02, PriceCacheWriteAbove: 0.25,
477 },
478}
479
480// DefaultModel is used when the user does not specify one.
481var DefaultModel = Catalog[0] // claude-sonnet-4-5
482
483// ----- active (merged) catalog -----
484//
485// Callers should use Active() / FindModel / ModelsForProvider for
486// lookups. They return the baked-in Catalog merged with any live
487// models loaded via SetLiveModels.
488
489var (
490 activeMu sync.RWMutex
491 active []Model // live overlay merged in via SetLiveModels; nil = none yet
492 activeSet bool // true once SetLiveModels has run (even with empty live)
493 authoritativeProviderSet map[string]struct{}
494 managedModels []Model // ephemeral models exposed by local model managers
495 userModels []Model // highest-precedence models loaded from models.json
496)
497
498// CatalogSnapshot captures the mutable catalog overlays so a caller can
499// restore provider state after temporarily installing test or runtime data.
500// Use SnapshotCatalog and RestoreCatalog instead of replacing the live
501// overlay with nil, which can discard another caller's active catalog.
502type CatalogSnapshot struct {
503 active []Model
504 activeSet bool
505 authoritativeProviderSet map[string]struct{}
506 managedModels []Model
507 userModels []Model
508}
509
510// SnapshotCatalog returns a deep copy of the mutable provider catalog state.
511func SnapshotCatalog() CatalogSnapshot {
512 activeMu.RLock()
513 defer activeMu.RUnlock()
514
515 snapshot := CatalogSnapshot{
516 activeSet: activeSet,
517 authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
518 }
519 if active != nil {
520 snapshot.active = cloneModels(active)
521 }
522 if managedModels != nil {
523 snapshot.managedModels = cloneModels(managedModels)
524 }
525 if userModels != nil {
526 snapshot.userModels = cloneModels(userModels)
527 }
528 return snapshot
529}
530
531// RestoreCatalog restores a snapshot returned by SnapshotCatalog.
532func RestoreCatalog(snapshot CatalogSnapshot) {
533 activeMu.Lock()
534 defer activeMu.Unlock()
535
536 if snapshot.active != nil {
537 active = cloneModels(snapshot.active)
538 } else {
539 active = nil
540 }
541 activeSet = snapshot.activeSet
542 authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
543 if snapshot.managedModels != nil {
544 managedModels = cloneModels(snapshot.managedModels)
545 } else {
546 managedModels = nil
547 }
548 if snapshot.userModels != nil {
549 userModels = cloneModels(snapshot.userModels)
550 } else {
551 userModels = nil
552 }
553}
554
555func cloneModel(model Model) Model {
556 model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
557 model.ReasoningEffortMap = maps.Clone(model.ReasoningEffortMap)
558 if model.PriceTiers != nil {
559 model.PriceTiers = append([]ModelPriceTier(nil), model.PriceTiers...)
560 }
561 return model
562}
563
564func cloneModels(models []Model) []Model {
565 out := make([]Model, len(models))
566 for i, model := range models {
567 out[i] = cloneModel(model)
568 }
569 return out
570}
571
572func mergeUserModels(active, users []Model) []Model {
573 if len(users) == 0 {
574 return active
575 }
576 byKey := func(provider, id string) string { return provider + "\x00" + id }
577 index := make(map[string]int, len(active))
578 for i, model := range active {
579 index[byKey(model.Provider, model.ID)] = i
580 }
581 for _, user := range users {
582 user = cloneModel(user)
583 key := byKey(user.Provider, user.ID)
584 if idx, ok := index[key]; ok {
585 existing := active[idx]
586 // Preserve catalog values when an override leaves an optional
587 // field at its zero value, matching models.json semantics.
588 // A positive price override replaces tiered pricing too;
589 // otherwise ComputeCost would retain discovered tier rates.
590 if user.PriceInput > 0 || user.PriceOutput > 0 || user.PriceCacheRead > 0 || user.PriceCacheWrite > 0 {
591 existing.PriceTiers = nil
592 existing.PriceTierInputTokens = 0
593 existing.PriceInputAbove, existing.PriceOutputAbove = 0, 0
594 existing.PriceCacheReadAbove, existing.PriceCacheWriteAbove = 0, 0
595 }
596 if user.PriceInput > 0 {
597 existing.PriceInput = user.PriceInput
598 }
599 if user.PriceOutput > 0 {
600 existing.PriceOutput = user.PriceOutput
601 }
602 if user.PriceCacheRead > 0 {
603 existing.PriceCacheRead = user.PriceCacheRead
604 }
605 if user.PriceCacheWrite > 0 {
606 existing.PriceCacheWrite = user.PriceCacheWrite
607 }
608 if user.DisplayName != "" {
609 existing.DisplayName = user.DisplayName
610 }
611 if user.ContextWindow > 0 {
612 existing.ContextWindow = user.ContextWindow
613 }
614 if user.MaxOutput > 0 {
615 existing.MaxOutput = user.MaxOutput
616 }
617 existing.Reasoning = user.Reasoning
618 if user.ReasoningLevelMap != nil {
619 existing.ReasoningLevelMap = user.ReasoningLevelMap
620 }
621 if user.ReasoningEffortMap != nil {
622 existing.ReasoningEffortMap = user.ReasoningEffortMap
623 }
624 if user.API != "" {
625 existing.API = user.API
626 }
627 if user.BaseURL != "" {
628 existing.BaseURL = user.BaseURL
629 }
630 existing.Source = "user"
631 existing.Speculative = false
632 active[idx] = existing
633 continue
634 }
635 index[key] = len(active)
636 active = append(active, user)
637 }
638 return active
639}
640
641// SetLiveModels replaces the "live" overlay used by the active catalog.
642// Typically called after a successful /v1/models discovery or on load
643// from the on-disk cache.
644func SetLiveModels(live []Model) {
645 SetLiveModelsForProviders(live, nil)
646}
647
648// SetLiveModelsForProviders replaces the live overlay, treating the listed
649// providers' live catalogs as authoritative. For those providers, static
650// entries omitted by discovery are removed from the active catalog.
651func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
652 activeMu.Lock()
653 defer activeMu.Unlock()
654 activeSet = true
655 authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders))
656 for _, name := range authoritativeProviders {
657 authoritativeProviderSet[name] = struct{}{}
658 }
659 if len(live) == 0 && len(authoritativeProviders) == 0 {
660 active = nil
661 return
662 }
663 active = MergeCatalogForProviders(live, authoritativeProviders)
664}
665
666// ClearLiveModelsForProvider removes a provider's live overlay without
667// disturbing other providers. User models remain separate and continue to
668// be applied by Active.
669func ClearLiveModelsForProvider(name string) {
670 activeMu.Lock()
671 defer activeMu.Unlock()
672 delete(authoritativeProviderSet, name)
673 if !activeSet || len(active) == 0 {
674 return
675 }
676 out := active[:0]
677 for _, model := range active {
678 if model.Provider != name {
679 out = append(out, model)
680 }
681 }
682 active = out
683}
684
685// Active returns the current merged catalog.
686//
687// When no live overlay has been set it returns the fully-assembled
688// static Catalog. Reading Catalog here (rather than capturing it into
689// a package-level var initializer) is load-bearing: the extended
690// catalog in catalog_builtin.go / extra_models.go is appended from
691// init() functions, which run AFTER package-level var initializers.
692// Snapshotting Catalog at var-init time would freeze the picker to the
693// curated seed list and drop every extra provider (openrouter, groq,
694// xai, ...). Deferring the read to call time avoids that ordering trap.
695func Active() []Model {
696 activeMu.RLock()
697 defer activeMu.RUnlock()
698 src := active
699 if !activeSet || src == nil {
700 src = Catalog
701 }
702 out := cloneModels(src)
703 if len(managedModels) > 0 {
704 index := make(map[string]int, len(out))
705 for i, model := range out {
706 index[model.Provider+"\x00"+model.ID] = i
707 }
708 for _, model := range managedModels {
709 model = cloneModel(model)
710 key := model.Provider + "\x00" + model.ID
711 if i, ok := index[key]; ok {
712 out[i] = model
713 continue
714 }
715 index[key] = len(out)
716 out = append(out, model)
717 }
718 }
719 // User models are a durable, highest-precedence overlay. Apply them
720 // after the live and managed overlays so a catalog refresh cannot
721 // discard overrides or user-only entries.
722 return mergeUserModels(out, userModels)
723}
724
725// SetManagedModels replaces the ephemeral catalog entries supplied by local
726// model managers. Unlike SetLiveModels, this does not disturb provider model
727// discovery or its on-disk cache.
728func SetManagedModels(models []Model) {
729 activeMu.Lock()
730 defer activeMu.Unlock()
731 managedModels = append([]Model(nil), models...)
732}
733
734// FindModel returns a Model by id, optionally constrained by provider.
735// If provider is empty, the first matching id is returned. Looks up
736// against the merged active catalog.
737func FindModel(provider, id string) (Model, error) {
738 for _, m := range Active() {
739 if m.ID == id && (provider == "" || m.Provider == provider) {
740 return m, nil
741 }
742 }
743 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
744}
745
746// IsProviderCatalogAuthoritative reports whether a successful live catalog
747// has declared the provider's available model IDs authoritative.
748func IsProviderCatalogAuthoritative(provider string) bool {
749 activeMu.RLock()
750 defer activeMu.RUnlock()
751 _, ok := authoritativeProviderSet[provider]
752 return ok
753}
754
755// AcceptsUnlistedModels reports whether a provider may be queried with an
756// ID that is not in the local catalog. OpenCode Go permits this only before
757// a successful authoritative live catalog has been loaded.
758func AcceptsUnlistedModels(provider string) bool {
759 return provider == ProviderOpenCodeGo && !IsProviderCatalogAuthoritative(provider)
760}
761
762// ModelsForProvider returns all models for the given provider, from the
763// merged active catalog.
764func ModelsForProvider(provider string) []Model {
765 var out []Model
766 for _, m := range Active() {
767 if m.Provider == provider {
768 out = append(out, m)
769 }
770 }
771 return out
772}
773
774// ComputeCost returns the USD cost for the given usage on model m.
775func ComputeCost(m Model, u Usage) float64 {
776 inputPrice := m.PriceInput
777 outputPrice := m.PriceOutput
778 cacheReadPrice := m.PriceCacheRead
779 cacheWritePrice := m.PriceCacheWrite
780 promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
781 if len(m.PriceTiers) > 0 {
782 selected := -1
783 for i, tier := range m.PriceTiers {
784 if tier.InputTokens <= promptTokens && (selected < 0 || tier.InputTokens > m.PriceTiers[selected].InputTokens) {
785 selected = i
786 }
787 }
788 if selected >= 0 {
789 tier := m.PriceTiers[selected]
790 inputPrice = tier.PriceInput
791 outputPrice = tier.PriceOutput
792 cacheReadPrice = tier.PriceCacheRead
793 cacheWritePrice = tier.PriceCacheWrite
794 }
795 } else if m.PriceTierInputTokens > 0 && promptTokens >= m.PriceTierInputTokens {
796 // Keep the legacy single-tier fields inclusive at the threshold.
797 inputPrice = m.PriceInputAbove
798 outputPrice = m.PriceOutputAbove
799 cacheReadPrice = m.PriceCacheReadAbove
800 cacheWritePrice = m.PriceCacheWriteAbove
801 }
802
803 const per = 1_000_000.0
804 return float64(u.InputTokens)*inputPrice/per +
805 float64(u.OutputTokens)*outputPrice/per +
806 float64(u.CacheReadTokens)*cacheReadPrice/per +
807 float64(u.CacheWriteTokens)*cacheWritePrice/per
808}