1package provider
2
3import (
4 "fmt"
5 "sync"
6)
7
8// Model describes a single LLM we know about.
9type Model struct {
10 Provider string // "anthropic" | "openai"
11 ID string // API id
12 DisplayName string
13 ContextWindow int
14 MaxOutput int
15 Reasoning bool // supports reasoning/thinking
16
17 // Prices are USD per 1M tokens.
18 PriceInput float64
19 PriceOutput float64
20 PriceCacheRead float64
21 PriceCacheWrite float64
22
23 // Speculative marks models whose ids are known from the upstream
24 // vendor's CLI but not yet live on their public API. They'll 404
25 // today but start working the moment the provider flips the switch.
26 Speculative bool
27
28 // BaseURL overrides the provider's default API endpoint for this
29 // model. Optional; when empty the provider's default (or the
30 // --base-url flag) is used. Useful for local models served by
31 // ollama, vLLM, LM Studio, etc.
32 BaseURL string
33
34 // Source is where this model entry came from: "catalog" (baked in),
35 // "live" (discovered via /v1/models), or "cache" (loaded from the
36 // on-disk cache). Informational.
37 Source string
38}
39
40// Catalog is the hardcoded, read-only list of supported models.
41// Prices are USD per 1M tokens. The list is curated to what zot's
42// clients (Anthropic Messages + OpenAI Chat Completions) can actually
43// talk to; models that are only reachable through the OpenAI Responses
44// API (o1-pro, o3-pro, gpt-5-pro) are omitted.
45var Catalog = []Model{
46 // ---- Anthropic / Claude 4.x ----
47 {
48 Provider: "anthropic", ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5 (latest)",
49 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
50 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
51 },
52 {
53 Provider: "anthropic", ID: "claude-opus-4-1", DisplayName: "Claude Opus 4.1 (latest)",
54 ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
55 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
56 },
57 {
58 Provider: "anthropic", ID: "claude-opus-4-0", DisplayName: "Claude Opus 4 (latest)",
59 ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
60 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
61 },
62 {
63 Provider: "anthropic", ID: "claude-sonnet-4-0", DisplayName: "Claude Sonnet 4 (latest)",
64 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
65 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
66 },
67 {
68 Provider: "anthropic", ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5 (latest)",
69 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
70 PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
71 },
72
73 // ---- Anthropic / Claude 3.x (legacy) ----
74 {
75 Provider: "anthropic", ID: "claude-3-7-sonnet-20250219", DisplayName: "Claude Sonnet 3.7",
76 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
77 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
78 },
79 {
80 Provider: "anthropic", ID: "claude-3-5-sonnet-20241022", DisplayName: "Claude Sonnet 3.5 v2",
81 ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
82 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
83 },
84 {
85 Provider: "anthropic", ID: "claude-3-5-haiku-latest", DisplayName: "Claude Haiku 3.5 (latest)",
86 ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
87 PriceInput: 0.8, PriceOutput: 4, PriceCacheRead: 0.08, PriceCacheWrite: 1,
88 },
89 {
90 Provider: "anthropic", ID: "claude-3-opus-20240229", DisplayName: "Claude Opus 3",
91 ContextWindow: 200000, MaxOutput: 4096, Reasoning: false,
92 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
93 },
94
95 // ---- DeepSeek ----
96 // The current public DeepSeek API exposes the V4 family on
97 // api.deepseek.com/v1. Pro is the flagship reasoning model;
98 // Flash is the cheaper/faster sibling. Both accept image inputs
99 // (multimodal parts: image_url) in addition to text.
100 {
101 Provider: "deepseek", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro",
102 ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
103 PriceInput: 0.435, PriceOutput: 0.87, PriceCacheRead: 0.003625,
104 BaseURL: "https://api.deepseek.com",
105 },
106 {
107 Provider: "deepseek", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash",
108 ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
109 PriceInput: 0.14, PriceOutput: 0.28, PriceCacheRead: 0.0028,
110 BaseURL: "https://api.deepseek.com",
111 },
112
113 // ---- Kimi / Kimi Code ----
114 // Anthropic-messages on https://api.kimi.com/coding (no /v1 suffix;
115 // the Anthropic client appends /v1/messages itself).
116 {
117 Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi For Coding",
118 ContextWindow: 262144, MaxOutput: 32768, Reasoning: true,
119 PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0,
120 BaseURL: "https://api.kimi.com/coding",
121 },
122
123 // ---- OpenAI / GPT-5 family ----
124 {
125 Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
126 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
127 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
128 },
129 {
130 Provider: "openai", ID: "gpt-5-mini", DisplayName: "GPT-5 Mini",
131 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
132 PriceInput: 0.25, PriceOutput: 2, PriceCacheRead: 0.025,
133 },
134 {
135 Provider: "openai", ID: "gpt-5-nano", DisplayName: "GPT-5 Nano",
136 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
137 PriceInput: 0.05, PriceOutput: 0.4, PriceCacheRead: 0.005,
138 },
139
140 // ---- OpenAI / GPT-4.1 family ----
141 {
142 Provider: "openai", ID: "gpt-4.1", DisplayName: "GPT-4.1",
143 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
144 PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
145 },
146 {
147 Provider: "openai", ID: "gpt-4.1-mini", DisplayName: "GPT-4.1 mini",
148 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
149 PriceInput: 0.4, PriceOutput: 1.6, PriceCacheRead: 0.1,
150 },
151 {
152 Provider: "openai", ID: "gpt-4.1-nano", DisplayName: "GPT-4.1 nano",
153 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
154 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.03,
155 },
156
157 // ---- OpenAI / GPT-4o family ----
158 {
159 Provider: "openai", ID: "gpt-4o", DisplayName: "GPT-4o",
160 ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
161 PriceInput: 2.5, PriceOutput: 10, PriceCacheRead: 1.25,
162 },
163 {
164 Provider: "openai", ID: "gpt-4o-mini", DisplayName: "GPT-4o mini",
165 ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
166 PriceInput: 0.15, PriceOutput: 0.6, PriceCacheRead: 0.08,
167 },
168
169 // ---- OpenAI / reasoning models ----
170 {
171 Provider: "openai", ID: "o4-mini", DisplayName: "o4-mini",
172 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
173 PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.28,
174 },
175 {
176 Provider: "openai", ID: "o3", DisplayName: "o3",
177 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
178 PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
179 },
180 {
181 Provider: "openai", ID: "o3-mini", DisplayName: "o3-mini",
182 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
183 PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.55,
184 },
185 {
186 Provider: "openai", ID: "o1", DisplayName: "o1",
187 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
188 PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5,
189 },
190
191 // ---- Google / Gemini ----
192 {
193 Provider: "google", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro",
194 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
195 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
196 },
197 {
198 Provider: "google", ID: "gemini-2.5-flash", DisplayName: "Gemini 2.5 Flash",
199 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
200 PriceInput: 0.3, PriceOutput: 2.5, PriceCacheRead: 0.03,
201 },
202 {
203 Provider: "google", ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash-Lite",
204 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
205 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.01,
206 },
207 {
208 Provider: "google", ID: "gemini-2.0-flash", DisplayName: "Gemini 2.0 Flash",
209 ContextWindow: 1048576, MaxOutput: 8192, Reasoning: false,
210 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.025,
211 },
212 {
213 Provider: "google", ID: "gemini-2.0-flash-lite", DisplayName: "Gemini 2.0 Flash-Lite",
214 ContextWindow: 1048576, MaxOutput: 8192, Reasoning: false,
215 PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0,
216 },
217
218 // ---- Speculative: Anthropic ----
219 {
220 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
221 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
222 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
223 Speculative: true,
224 },
225 {
226 Provider: "anthropic", ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6",
227 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
228 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
229 Speculative: true,
230 },
231 {
232 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
233 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
234 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
235 Speculative: true,
236 },
237 {
238 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
239 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
240 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
241 Speculative: true,
242 },
243 {
244 Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
245 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
246 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
247 Speculative: true,
248 },
249
250 // ---- Speculative: OpenAI ----
251 // Public OpenAI API route. The ChatGPT/Codex subscription route is
252 // represented separately below as provider "openai-codex".
253 {
254 Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
255 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
256 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
257 Speculative: true,
258 },
259 {
260 Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
261 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
262 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
263 Speculative: true,
264 },
265 {
266 Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
267 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
268 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
269 Speculative: true,
270 },
271 {
272 Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
273 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
274 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
275 Speculative: true,
276 },
277 {
278 Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
279 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
280 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
281 Speculative: true,
282 },
283
284 // ---- OpenAI Codex / ChatGPT subscription backend ----
285 // Same model ids as the OpenAI family, but routed through the
286 // ChatGPT Codex OAuth backend rather than api.openai.com.
287 {
288 Provider: "openai-codex", ID: "gpt-5.2", DisplayName: "GPT-5.2",
289 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
290 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
291 Speculative: true,
292 },
293 {
294 Provider: "openai-codex", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex",
295 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
296 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
297 Speculative: true,
298 },
299 {
300 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
301 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
302 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
303 Speculative: true,
304 },
305 {
306 Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
307 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
308 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
309 Speculative: true,
310 },
311 {
312 Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
313 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
314 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
315 Speculative: true,
316 },
317 {
318 Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
319 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
320 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
321 Speculative: true,
322 },
323}
324
325// DefaultModel is used when the user does not specify one.
326var DefaultModel = Catalog[0] // claude-sonnet-4-5
327
328// ----- active (merged) catalog -----
329//
330// Callers should use Active() / FindModel / ModelsForProvider for
331// lookups. They return the baked-in Catalog merged with any live
332// models loaded via SetLiveModels.
333
334var (
335 activeMu sync.RWMutex
336 active []Model // live overlay merged in via SetLiveModels; nil = none yet
337 activeSet bool // true once SetLiveModels has run (even with empty live)
338)
339
340// SetLiveModels replaces the "live" overlay used by the active catalog.
341// Typically called after a successful /v1/models discovery or on load
342// from the on-disk cache.
343func SetLiveModels(live []Model) {
344 activeMu.Lock()
345 defer activeMu.Unlock()
346 activeSet = true
347 if len(live) == 0 {
348 active = nil
349 return
350 }
351 active = MergeCatalog(live)
352}
353
354// Active returns the current merged catalog.
355//
356// When no live overlay has been set it returns the fully-assembled
357// static Catalog. Reading Catalog here (rather than capturing it into
358// a package-level var initializer) is load-bearing: the extended
359// catalog in catalog_builtin.go / extra_models.go is appended from
360// init() functions, which run AFTER package-level var initializers.
361// Snapshotting Catalog at var-init time would freeze the picker to the
362// curated seed list and drop every extra provider (openrouter, groq,
363// xai, ...). Deferring the read to call time avoids that ordering trap.
364func Active() []Model {
365 activeMu.RLock()
366 defer activeMu.RUnlock()
367 src := active
368 if !activeSet || src == nil {
369 src = Catalog
370 }
371 out := make([]Model, len(src))
372 copy(out, src)
373 return out
374}
375
376// FindModel returns a Model by id, optionally constrained by provider.
377// If provider is empty, the first matching id is returned. Looks up
378// against the merged active catalog.
379func FindModel(provider, id string) (Model, error) {
380 for _, m := range Active() {
381 if m.ID == id && (provider == "" || m.Provider == provider) {
382 return m, nil
383 }
384 }
385 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
386}
387
388// ModelsForProvider returns all models for the given provider, from the
389// merged active catalog.
390func ModelsForProvider(provider string) []Model {
391 var out []Model
392 for _, m := range Active() {
393 if m.Provider == provider {
394 out = append(out, m)
395 }
396 }
397 return out
398}
399
400// ComputeCost returns the USD cost for the given usage on model m.
401func ComputeCost(m Model, u Usage) float64 {
402 const per = 1_000_000.0
403 return float64(u.InputTokens)*m.PriceInput/per +
404 float64(u.OutputTokens)*m.PriceOutput/per +
405 float64(u.CacheReadTokens)*m.PriceCacheRead/per +
406 float64(u.CacheWriteTokens)*m.PriceCacheWrite/per
407}