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