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