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 zut'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 // ---- Speculative: Anthropic ----
258 {
259 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
260 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
261 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
262 Speculative: true,
263 },
264 {
265 Provider: "anthropic", ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6",
266 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
267 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
268 Speculative: true,
269 },
270 {
271 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
272 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
273 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
274 Speculative: true,
275 },
276 {
277 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
278 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
279 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
280 Speculative: true,
281 },
282 {
283 Provider: "anthropic", ID: "claude-opus-5", DisplayName: "Claude Opus 5",
284 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
285 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
286 },
287 {
288 Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
289 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
290 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
291 Speculative: true,
292 },
293 {
294 Provider: "anthropic", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
295 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
296 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
297 },
298 {
299 Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
300 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
301 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
302 Speculative: true,
303 },
304
305 // ---- Speculative: OpenAI ----
306 // Public OpenAI API route. The ChatGPT/Codex subscription route is
307 // represented separately below as provider "openai-codex".
308 {
309 Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
310 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
311 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
312 Speculative: true,
313 },
314 {
315 Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
316 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
317 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
318 Speculative: true,
319 },
320 {
321 Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
322 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
323 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
324 Speculative: true,
325 },
326 {
327 Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
328 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
329 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
330 Speculative: true,
331 },
332 {
333 Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
334 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
335 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
336 Speculative: true,
337 },
338 {
339 Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
340 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
341 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
342 Speculative: true,
343 },
344 {
345 Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
346 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
347 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
348 Speculative: true,
349 },
350 {
351 Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
352 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
353 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
354 Speculative: true,
355 },
356 // ---- OpenAI Codex / ChatGPT subscription backend ----
357 // Same model ids as the OpenAI family, but routed through the
358 // ChatGPT Codex OAuth backend rather than api.openai.com.
359 {
360 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
361 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
362 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
363 Speculative: true,
364 },
365 {
366 Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
367 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
368 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
369 Speculative: true,
370 },
371 {
372 Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
373 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
374 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
375 Speculative: true,
376 },
377 {
378 Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
379 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
380 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
381 Speculative: true,
382 },
383 {
384 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
385 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
386 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
387 },
388 {
389 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
390 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
391 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
392 },
393 {
394 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
395 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
396 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
397 },
398}
399
400// DefaultModel is used when the user does not specify one.
401var DefaultModel = Catalog[0] // claude-sonnet-4-5
402
403// ----- active (merged) catalog -----
404//
405// Callers should use Active() / FindModel / ModelsForProvider for
406// lookups. They return the baked-in Catalog merged with any live
407// models loaded via SetLiveModels.
408
409var (
410 activeMu sync.RWMutex
411 active []Model // live overlay merged in via SetLiveModels; nil = none yet
412 activeSet bool // true once SetLiveModels has run (even with empty live)
413 managedModels []Model // ephemeral models exposed by local model managers
414)
415
416// SetLiveModels replaces the "live" overlay used by the active catalog.
417// Typically called after a successful /v1/models discovery or on load
418// from the on-disk cache.
419func SetLiveModels(live []Model) {
420 SetLiveModelsForProviders(live, nil)
421}
422
423// SetLiveModelsForProviders replaces the live overlay, treating the listed
424// providers' live catalogs as authoritative. For those providers, static
425// entries omitted by discovery are removed from the active catalog.
426func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
427 activeMu.Lock()
428 defer activeMu.Unlock()
429 activeSet = true
430 if len(live) == 0 && len(authoritativeProviders) == 0 {
431 active = nil
432 return
433 }
434 active = MergeCatalogForProviders(live, authoritativeProviders)
435}
436
437// Active returns the current merged catalog.
438//
439// When no live overlay has been set it returns the fully-assembled
440// static Catalog. Reading Catalog here (rather than capturing it into
441// a package-level var initializer) is load-bearing: the extended
442// catalog in catalog_builtin.go / extra_models.go is appended from
443// init() functions, which run AFTER package-level var initializers.
444// Snapshotting Catalog at var-init time would freeze the picker to the
445// curated seed list and drop every extra provider (openrouter, groq,
446// xai, ...). Deferring the read to call time avoids that ordering trap.
447func Active() []Model {
448 activeMu.RLock()
449 defer activeMu.RUnlock()
450 src := active
451 if !activeSet || src == nil {
452 src = Catalog
453 }
454 out := make([]Model, len(src))
455 copy(out, src)
456 for i := range out {
457 out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
458 }
459 if len(managedModels) == 0 {
460 return out
461 }
462 index := make(map[string]int, len(out))
463 for i, model := range out {
464 index[model.Provider+"\x00"+model.ID] = i
465 }
466 for _, model := range managedModels {
467 model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
468 key := model.Provider + "\x00" + model.ID
469 if i, ok := index[key]; ok {
470 out[i] = model
471 continue
472 }
473 index[key] = len(out)
474 out = append(out, model)
475 }
476 return out
477}
478
479// SetManagedModels replaces the ephemeral catalog entries supplied by local
480// model managers. Unlike SetLiveModels, this does not disturb provider model
481// discovery or its on-disk cache.
482func SetManagedModels(models []Model) {
483 activeMu.Lock()
484 defer activeMu.Unlock()
485 managedModels = append([]Model(nil), models...)
486}
487
488// FindModel returns a Model by id, optionally constrained by provider.
489// If provider is empty, the first matching id is returned. Looks up
490// against the merged active catalog.
491func FindModel(provider, id string) (Model, error) {
492 for _, m := range Active() {
493 if m.ID == id && (provider == "" || m.Provider == provider) {
494 return m, nil
495 }
496 }
497 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
498}
499
500// ModelsForProvider returns all models for the given provider, from the
501// merged active catalog.
502func ModelsForProvider(provider string) []Model {
503 var out []Model
504 for _, m := range Active() {
505 if m.Provider == provider {
506 out = append(out, m)
507 }
508 }
509 return out
510}
511
512// ComputeCost returns the USD cost for the given usage on model m.
513func ComputeCost(m Model, u Usage) float64 {
514 inputPrice := m.PriceInput
515 outputPrice := m.PriceOutput
516 cacheReadPrice := m.PriceCacheRead
517 cacheWritePrice := m.PriceCacheWrite
518 promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
519 if m.PriceTierInputTokens > 0 && promptTokens > m.PriceTierInputTokens {
520 inputPrice = m.PriceInputAbove
521 outputPrice = m.PriceOutputAbove
522 cacheReadPrice = m.PriceCacheReadAbove
523 cacheWritePrice = m.PriceCacheWriteAbove
524 }
525
526 const per = 1_000_000.0
527 return float64(u.InputTokens)*inputPrice/per +
528 float64(u.OutputTokens)*outputPrice/per +
529 float64(u.CacheReadTokens)*cacheReadPrice/per +
530 float64(u.CacheWriteTokens)*cacheWritePrice/per
531}