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 modelAvailability = map[string]map[string]bool{} // account-specific, memory only
497)
498
499// CatalogSnapshot captures the mutable catalog overlays so a caller can
500// restore provider state after temporarily installing test or runtime data.
501// Use SnapshotCatalog and RestoreCatalog instead of replacing the live
502// overlay with nil, which can discard another caller's active catalog.
503type CatalogSnapshot struct {
504 active []Model
505 activeSet bool
506 authoritativeProviderSet map[string]struct{}
507 managedModels []Model
508 userModels []Model
509 modelAvailability map[string]map[string]bool
510}
511
512// SnapshotCatalog returns a deep copy of the mutable provider catalog state.
513func SnapshotCatalog() CatalogSnapshot {
514 activeMu.RLock()
515 defer activeMu.RUnlock()
516
517 snapshot := CatalogSnapshot{
518 activeSet: activeSet,
519 authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
520 modelAvailability: cloneModelAvailability(modelAvailability),
521 }
522 if active != nil {
523 snapshot.active = cloneModels(active)
524 }
525 if managedModels != nil {
526 snapshot.managedModels = cloneModels(managedModels)
527 }
528 if userModels != nil {
529 snapshot.userModels = cloneModels(userModels)
530 }
531 return snapshot
532}
533
534// RestoreCatalog restores a snapshot returned by SnapshotCatalog.
535func RestoreCatalog(snapshot CatalogSnapshot) {
536 activeMu.Lock()
537 defer activeMu.Unlock()
538
539 if snapshot.active != nil {
540 active = cloneModels(snapshot.active)
541 } else {
542 active = nil
543 }
544 activeSet = snapshot.activeSet
545 authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
546 modelAvailability = cloneModelAvailability(snapshot.modelAvailability)
547 if modelAvailability == nil {
548 modelAvailability = map[string]map[string]bool{}
549 }
550 if snapshot.managedModels != nil {
551 managedModels = cloneModels(snapshot.managedModels)
552 } else {
553 managedModels = nil
554 }
555 if snapshot.userModels != nil {
556 userModels = cloneModels(snapshot.userModels)
557 } else {
558 userModels = nil
559 }
560}
561
562func cloneModel(model Model) Model {
563 model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
564 model.ReasoningEffortMap = maps.Clone(model.ReasoningEffortMap)
565 if model.PriceTiers != nil {
566 model.PriceTiers = append([]ModelPriceTier(nil), model.PriceTiers...)
567 }
568 return model
569}
570
571func cloneModels(models []Model) []Model {
572 out := make([]Model, len(models))
573 for i, model := range models {
574 out[i] = cloneModel(model)
575 }
576 return out
577}
578
579func mergeUserModels(active, users []Model) []Model {
580 if len(users) == 0 {
581 return active
582 }
583 byKey := func(provider, id string) string { return provider + "\x00" + id }
584 index := make(map[string]int, len(active))
585 for i, model := range active {
586 index[byKey(model.Provider, model.ID)] = i
587 }
588 for _, user := range users {
589 user = cloneModel(user)
590 key := byKey(user.Provider, user.ID)
591 if idx, ok := index[key]; ok {
592 existing := active[idx]
593 // Preserve catalog values when an override leaves an optional
594 // field at its zero value, matching models.json semantics.
595 // A positive price override replaces tiered pricing too;
596 // otherwise ComputeCost would retain discovered tier rates.
597 if user.PriceInput > 0 || user.PriceOutput > 0 || user.PriceCacheRead > 0 || user.PriceCacheWrite > 0 {
598 existing.PriceTiers = nil
599 existing.PriceTierInputTokens = 0
600 existing.PriceInputAbove, existing.PriceOutputAbove = 0, 0
601 existing.PriceCacheReadAbove, existing.PriceCacheWriteAbove = 0, 0
602 }
603 if user.PriceInput > 0 {
604 existing.PriceInput = user.PriceInput
605 }
606 if user.PriceOutput > 0 {
607 existing.PriceOutput = user.PriceOutput
608 }
609 if user.PriceCacheRead > 0 {
610 existing.PriceCacheRead = user.PriceCacheRead
611 }
612 if user.PriceCacheWrite > 0 {
613 existing.PriceCacheWrite = user.PriceCacheWrite
614 }
615 if user.DisplayName != "" {
616 existing.DisplayName = user.DisplayName
617 }
618 if user.ContextWindow > 0 {
619 existing.ContextWindow = user.ContextWindow
620 }
621 if user.MaxOutput > 0 {
622 existing.MaxOutput = user.MaxOutput
623 }
624 existing.Reasoning = user.Reasoning
625 if user.ReasoningLevelMap != nil {
626 existing.ReasoningLevelMap = user.ReasoningLevelMap
627 }
628 if user.ReasoningEffortMap != nil {
629 existing.ReasoningEffortMap = user.ReasoningEffortMap
630 }
631 if user.API != "" {
632 existing.API = user.API
633 }
634 if user.BaseURL != "" {
635 existing.BaseURL = user.BaseURL
636 }
637 existing.Source = "user"
638 existing.Speculative = false
639 if existing.Provider == ProviderGitHubCopilot {
640 configureCopilotModel(&existing) // normalizes a legacy api value
641 }
642 active[idx] = existing
643 continue
644 }
645 if user.Provider == ProviderGitHubCopilot {
646 // A user-only Copilot model has no catalog row, so it would miss the
647 // protocol-specific defaults built-in rows receive. Explicit API and
648 // reasoning mappings from models.json are preserved.
649 configureCopilotModel(&user)
650 }
651 index[key] = len(active)
652 active = append(active, user)
653 }
654 return active
655}
656
657// SetLiveModels replaces the "live" overlay used by the active catalog.
658// Typically called after a successful /v1/models discovery or on load
659// from the on-disk cache.
660func SetLiveModels(live []Model) {
661 SetLiveModelsForProviders(live, nil)
662}
663
664// SetLiveModelsForProviders replaces the live overlay, treating the listed
665// providers' live catalogs as authoritative. For those providers, static
666// entries omitted by discovery are removed from the active catalog.
667func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
668 activeMu.Lock()
669 defer activeMu.Unlock()
670 activeSet = true
671 authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders))
672 for _, name := range authoritativeProviders {
673 authoritativeProviderSet[name] = struct{}{}
674 }
675 if len(live) == 0 && len(authoritativeProviders) == 0 {
676 active = nil
677 return
678 }
679 active = MergeCatalogForProviders(live, authoritativeProviders)
680}
681
682// ClearLiveModelsForProvider removes a provider's live overlay without
683// disturbing other providers. User models remain separate and continue to
684// be applied by Active.
685func ClearLiveModelsForProvider(name string) {
686 activeMu.Lock()
687 defer activeMu.Unlock()
688 delete(authoritativeProviderSet, name)
689 if !activeSet || len(active) == 0 {
690 return
691 }
692 out := active[:0]
693 for _, model := range active {
694 if model.Provider != name {
695 out = append(out, model)
696 }
697 }
698 active = out
699}
700
701// Active returns the current merged catalog.
702//
703// When no live overlay has been set it returns the fully-assembled
704// static Catalog. Reading Catalog here (rather than capturing it into
705// a package-level var initializer) is load-bearing: the extended
706// catalog in catalog_builtin.go / extra_models.go is appended from
707// init() functions, which run AFTER package-level var initializers.
708// Snapshotting Catalog at var-init time would freeze the picker to the
709// curated seed list and drop every extra provider (openrouter, groq,
710// xai, ...). Deferring the read to call time avoids that ordering trap.
711func Active() []Model { return activeModels(true) }
712
713// activeModels builds the merged catalog. When availableOnly is set, providers
714// with an account availability snapshot expose only the models it lists.
715func activeModels(availableOnly bool) []Model {
716 activeMu.RLock()
717 defer activeMu.RUnlock()
718 src := active
719 if !activeSet || src == nil {
720 src = Catalog
721 }
722 out := cloneModels(src)
723 if len(managedModels) > 0 {
724 index := make(map[string]int, len(out))
725 for i, model := range out {
726 index[model.Provider+"\x00"+model.ID] = i
727 }
728 for _, model := range managedModels {
729 model = cloneModel(model)
730 key := model.Provider + "\x00" + model.ID
731 if i, ok := index[key]; ok {
732 out[i] = model
733 continue
734 }
735 index[key] = len(out)
736 out = append(out, model)
737 }
738 }
739 // User models are a durable, highest-precedence overlay. Apply them
740 // after the live and managed overlays so a catalog refresh cannot
741 // discard overrides or user-only entries.
742 out = mergeUserModels(out, userModels)
743 if availableOnly {
744 out = filterAvailableModels(out)
745 }
746 return out
747}
748
749func cloneModelAvailability(in map[string]map[string]bool) map[string]map[string]bool {
750 if in == nil {
751 return nil
752 }
753 out := make(map[string]map[string]bool, len(in))
754 for name, ids := range in {
755 out[name] = maps.Clone(ids)
756 }
757 return out
758}
759
760// SetModelAvailability restricts a provider's visible catalog to the supplied
761// account model IDs. A nil slice clears the restriction; an empty non-nil slice
762// hides all of the provider's models. The snapshot is memory-only: it is never
763// written to the shared model cache because it is account-specific.
764func SetModelAvailability(provider string, ids []string) {
765 activeMu.Lock()
766 defer activeMu.Unlock()
767 if ids == nil {
768 delete(modelAvailability, provider)
769 return
770 }
771 allowed := make(map[string]bool, len(ids))
772 for _, id := range ids {
773 allowed[id] = true
774 }
775 modelAvailability[provider] = allowed
776}
777
778// filterAvailableModels removes models hidden by account availability. The
779// caller holds activeMu and owns models.
780func filterAvailableModels(models []Model) []Model {
781 if len(modelAvailability) == 0 {
782 return models
783 }
784 out := models[:0]
785 for _, m := range models {
786 allowed, restricted := modelAvailability[m.Provider]
787 if !restricted || allowed[m.ID] {
788 out = append(out, m)
789 }
790 }
791 return out
792}
793
794// SetManagedModels replaces the ephemeral catalog entries supplied by local
795// model managers. Unlike SetLiveModels, this does not disturb provider model
796// discovery or its on-disk cache.
797func SetManagedModels(models []Model) {
798 activeMu.Lock()
799 defer activeMu.Unlock()
800 managedModels = append([]Model(nil), models...)
801}
802
803// SetManagedModelsForProvider replaces only one provider's managed entries,
804// leaving other managers' snapshots (llama.cpp, custom discovery, ...) intact.
805// Entries whose Provider differs from providerID are ignored.
806func SetManagedModelsForProvider(providerID string, models []Model) {
807 activeMu.Lock()
808 defer activeMu.Unlock()
809 kept := make([]Model, 0, len(managedModels)+len(models))
810 for _, m := range managedModels {
811 if m.Provider != providerID {
812 kept = append(kept, m)
813 }
814 }
815 for _, m := range models {
816 if m.Provider == providerID {
817 kept = append(kept, cloneModel(m))
818 }
819 }
820 managedModels = kept
821}
822
823// FindModel returns a Model by id, optionally constrained by provider.
824// If provider is empty, the first matching id is returned. Looks up
825// against the merged catalog, including models hidden by account availability:
826// existing sessions still need stable wire and pricing metadata after a refresh.
827func FindModel(provider, id string) (Model, error) {
828 for _, m := range activeModels(false) {
829 if m.ID == id && (provider == "" || m.Provider == provider) {
830 return m, nil
831 }
832 }
833 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
834}
835
836// IsProviderCatalogAuthoritative reports whether a successful live catalog
837// has declared the provider's available model IDs authoritative.
838func IsProviderCatalogAuthoritative(provider string) bool {
839 activeMu.RLock()
840 defer activeMu.RUnlock()
841 _, ok := authoritativeProviderSet[provider]
842 return ok
843}
844
845// AcceptsUnlistedModels reports whether a provider may be queried with an
846// ID that is not in the local catalog. OpenCode Go permits this only before
847// a successful authoritative live catalog has been loaded.
848func AcceptsUnlistedModels(provider string) bool {
849 return provider == ProviderOpenCodeGo && !IsProviderCatalogAuthoritative(provider)
850}
851
852// ModelsForProvider returns all models for the given provider, from the
853// merged active catalog.
854func ModelsForProvider(provider string) []Model {
855 var out []Model
856 for _, m := range Active() {
857 if m.Provider == provider {
858 out = append(out, m)
859 }
860 }
861 return out
862}
863
864// ComputeCost returns the USD cost for the given usage on model m.
865func ComputeCost(m Model, u Usage) float64 {
866 inputPrice := m.PriceInput
867 outputPrice := m.PriceOutput
868 cacheReadPrice := m.PriceCacheRead
869 cacheWritePrice := m.PriceCacheWrite
870 promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
871 if len(m.PriceTiers) > 0 {
872 selected := -1
873 for i, tier := range m.PriceTiers {
874 if tier.InputTokens <= promptTokens && (selected < 0 || tier.InputTokens > m.PriceTiers[selected].InputTokens) {
875 selected = i
876 }
877 }
878 if selected >= 0 {
879 tier := m.PriceTiers[selected]
880 inputPrice = tier.PriceInput
881 outputPrice = tier.PriceOutput
882 cacheReadPrice = tier.PriceCacheRead
883 cacheWritePrice = tier.PriceCacheWrite
884 }
885 } else if m.PriceTierInputTokens > 0 && promptTokens >= m.PriceTierInputTokens {
886 // Keep the legacy single-tier fields inclusive at the threshold.
887 inputPrice = m.PriceInputAbove
888 outputPrice = m.PriceOutputAbove
889 cacheReadPrice = m.PriceCacheReadAbove
890 cacheWritePrice = m.PriceCacheWriteAbove
891 }
892
893 const per = 1_000_000.0
894 return float64(u.InputTokens)*inputPrice/per +
895 float64(u.OutputTokens)*outputPrice/per +
896 float64(u.CacheReadTokens)*cacheReadPrice/per +
897 float64(u.CacheWriteTokens)*cacheWritePrice/per
898}