1package provider
2
3import (
4 "fmt"
5 "maps"
6 "sync"
7)
8
9const openAIContextWindowTarget = 500_000
10
11// Model describes a single LLM we know about.
12type Model struct {
13 Provider string // "anthropic" | "openai"
14 ID string // API id
15 DisplayName string
16 API string // wire API override for providers that support multiple protocols
17 ContextWindow int
18 MaxOutput int
19 Reasoning bool // supports reasoning
20 ReasoningLevelMap map[string]string // optional level overrides; empty values remove a level
21
22 // AdaptiveThinking marks Anthropic models that only support the
23 // adaptive thinking mode (Opus 4.7+). These reject explicit
24 // thinking budgets (thinking:{type:"enabled",budget_tokens:N} -> 400)
25 // and also reject non-default sampling params (temperature/top_p/
26 // top_k). The Anthropic client sends thinking:{type:"adaptive"} plus
27 // output_config.effort and omits temperature for these models.
28 AdaptiveThinking bool
29
30 // AdaptiveThinkingCompat marks Anthropic-compatible models that require
31 // thinking:{type:"adaptive"} but still accept sampling parameters and do
32 // not support Anthropic's output_config.effort extension.
33 AdaptiveThinkingCompat bool
34
35 // Prices are USD per 1M tokens. Price*Above fields apply to all token
36 // classes when the total prompt size exceeds PriceTierInputTokens.
37 PriceInput float64
38 PriceOutput float64
39 PriceCacheRead float64
40 PriceCacheWrite float64
41 PriceTierInputTokens int
42 PriceInputAbove float64
43 PriceOutputAbove float64
44 PriceCacheReadAbove float64
45 PriceCacheWriteAbove float64
46
47 // Speculative marks models whose ids are known from the upstream
48 // vendor's CLI but not yet live on their public API. They'll 404
49 // today but start working the moment the provider flips the switch.
50 Speculative bool
51
52 // BaseURL overrides the provider's default API endpoint for this
53 // model. Optional; when empty the provider's default (or the
54 // --base-url flag) is used. Useful for local models served by
55 // ollama, vLLM, LM Studio, etc.
56 BaseURL string
57
58 // Source is where this model entry came from: "catalog" (baked in),
59 // "live" (discovered via /v1/models), or "cache" (loaded from the
60 // on-disk cache). Informational.
61 Source string
62}
63
64// Catalog is the hardcoded, read-only list of supported models.
65// Prices are USD per 1M tokens. The list is curated to what zut's
66// clients (Anthropic Messages + OpenAI Chat Completions) can actually
67// talk to; models that are only reachable through the OpenAI Responses
68// API (o1-pro, o3-pro, gpt-5-pro) are omitted.
69var Catalog = []Model{
70 // ---- Anthropic / Claude 4.x ----
71 {
72 Provider: "anthropic", ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5 (latest)",
73 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
74 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
75 },
76 {
77 Provider: "anthropic", ID: "claude-opus-4-1", DisplayName: "Claude Opus 4.1 (latest)",
78 ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
79 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
80 },
81 {
82 Provider: "anthropic", ID: "claude-opus-4-0", DisplayName: "Claude Opus 4 (latest)",
83 ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
84 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
85 },
86 {
87 Provider: "anthropic", ID: "claude-sonnet-4-0", DisplayName: "Claude Sonnet 4 (latest)",
88 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
89 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
90 },
91 {
92 Provider: "anthropic", ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5 (latest)",
93 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
94 PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
95 },
96
97 // ---- Anthropic / Claude 3.x (legacy) ----
98 {
99 Provider: "anthropic", ID: "claude-3-7-sonnet-20250219", DisplayName: "Claude Sonnet 3.7",
100 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
101 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
102 },
103 {
104 Provider: "anthropic", ID: "claude-3-5-sonnet-20241022", DisplayName: "Claude Sonnet 3.5 v2",
105 ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
106 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
107 },
108 {
109 Provider: "anthropic", ID: "claude-3-5-haiku-latest", DisplayName: "Claude Haiku 3.5 (latest)",
110 ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
111 PriceInput: 0.8, PriceOutput: 4, PriceCacheRead: 0.08, PriceCacheWrite: 1,
112 },
113 {
114 Provider: "anthropic", ID: "claude-3-opus-20240229", DisplayName: "Claude Opus 3",
115 ContextWindow: 200000, MaxOutput: 4096, Reasoning: false,
116 PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
117 },
118
119 // ---- DeepSeek ----
120 // The current public DeepSeek API exposes the V4 family on
121 // api.deepseek.com/v1. Pro is the flagship reasoning model;
122 // Flash is the cheaper/faster sibling. Both accept image inputs
123 // (multimodal parts: image_url) in addition to text.
124 {
125 Provider: "deepseek", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro",
126 ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
127 PriceInput: 0.435, PriceOutput: 0.87, PriceCacheRead: 0.003625,
128 BaseURL: "https://api.deepseek.com",
129 },
130 {
131 Provider: "deepseek", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash",
132 ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
133 PriceInput: 0.14, PriceOutput: 0.28, PriceCacheRead: 0.0028,
134 BaseURL: "https://api.deepseek.com",
135 },
136
137 // ---- Kimi / Kimi Code ----
138 // Anthropic-messages on https://api.kimi.com/coding (no /v1 suffix;
139 // the Anthropic client appends /v1/messages itself).
140 {
141 Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi For Coding",
142 ContextWindow: 262144, MaxOutput: 32768, Reasoning: true,
143 PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0,
144 BaseURL: "https://api.kimi.com/coding",
145 },
146
147 // ---- OpenAI / GPT-5 family ----
148 {
149 Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
150 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
151 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
152 },
153 {
154 Provider: "openai", ID: "gpt-5-mini", DisplayName: "GPT-5 Mini",
155 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
156 PriceInput: 0.25, PriceOutput: 2, PriceCacheRead: 0.025,
157 },
158 {
159 Provider: "openai", ID: "gpt-5-nano", DisplayName: "GPT-5 Nano",
160 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
161 PriceInput: 0.05, PriceOutput: 0.4, PriceCacheRead: 0.005,
162 },
163
164 // ---- OpenAI / GPT-4.1 family ----
165 {
166 Provider: "openai", ID: "gpt-4.1", DisplayName: "GPT-4.1",
167 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
168 PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
169 },
170 {
171 Provider: "openai", ID: "gpt-4.1-mini", DisplayName: "GPT-4.1 mini",
172 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
173 PriceInput: 0.4, PriceOutput: 1.6, PriceCacheRead: 0.1,
174 },
175 {
176 Provider: "openai", ID: "gpt-4.1-nano", DisplayName: "GPT-4.1 nano",
177 ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
178 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.03,
179 },
180
181 // ---- OpenAI / GPT-4o family ----
182 {
183 Provider: "openai", ID: "gpt-4o", DisplayName: "GPT-4o",
184 ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
185 PriceInput: 2.5, PriceOutput: 10, PriceCacheRead: 1.25,
186 },
187 {
188 Provider: "openai", ID: "gpt-4o-mini", DisplayName: "GPT-4o mini",
189 ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
190 PriceInput: 0.15, PriceOutput: 0.6, PriceCacheRead: 0.08,
191 },
192
193 // ---- OpenAI / reasoning models ----
194 {
195 Provider: "openai", ID: "o4-mini", DisplayName: "o4-mini",
196 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
197 PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.28,
198 },
199 {
200 Provider: "openai", ID: "o3", DisplayName: "o3",
201 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
202 PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
203 },
204 {
205 Provider: "openai", ID: "o3-mini", DisplayName: "o3-mini",
206 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
207 PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.55,
208 },
209 {
210 Provider: "openai", ID: "o1", DisplayName: "o1",
211 ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
212 PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5,
213 },
214
215 // ---- Google / Gemini ----
216 {
217 Provider: "google", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro",
218 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
219 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
220 },
221 {
222 Provider: "google", ID: "gemini-2.5-flash", DisplayName: "Gemini 2.5 Flash",
223 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
224 PriceInput: 0.3, PriceOutput: 2.5, PriceCacheRead: 0.03,
225 },
226 {
227 Provider: "google", ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash-Lite",
228 ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true,
229 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.01,
230 },
231 {
232 Provider: "google", ID: "gemini-2.0-flash", DisplayName: "Gemini 2.0 Flash",
233 ContextWindow: 1048576, MaxOutput: 8192, Reasoning: false,
234 PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.025,
235 },
236 {
237 Provider: "google", ID: "gemini-2.0-flash-lite", DisplayName: "Gemini 2.0 Flash-Lite",
238 ContextWindow: 1048576, MaxOutput: 8192, Reasoning: false,
239 PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0,
240 },
241
242 // ---- OpenRouter ----
243 // Seed entry only: the default model returned by defaultModelForProvider
244 // so it resolves offline. The full OpenRouter catalog is discovered live
245 // (DiscoverOpenRouter) and overlays this on refresh.
246 {
247 Provider: "openrouter", ID: "anthropic/claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5 (OpenRouter)",
248 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
249 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
250 BaseURL: openrouterDefaultBaseURL,
251 },
252 {
253 Provider: "openrouter", ID: "anthropic/claude-sonnet-5", DisplayName: "Claude Sonnet 5 (OpenRouter)",
254 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
255 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
256 BaseURL: openrouterDefaultBaseURL,
257 },
258
259 // ---- Speculative: Anthropic ----
260 {
261 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
262 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
263 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
264 Speculative: true,
265 },
266 {
267 Provider: "anthropic", ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6",
268 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
269 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
270 Speculative: true,
271 },
272 {
273 Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
274 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
275 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
276 Speculative: true,
277 },
278 {
279 Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
280 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
281 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
282 Speculative: true,
283 },
284 {
285 Provider: "anthropic", ID: "claude-opus-5", DisplayName: "Claude Opus 5",
286 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
287 PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
288 },
289 {
290 Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
291 ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
292 PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
293 Speculative: true,
294 },
295 {
296 Provider: "anthropic", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
297 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
298 PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
299 },
300 {
301 Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
302 ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
303 PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
304 Speculative: true,
305 },
306
307 // ---- Speculative: OpenAI ----
308 // Public OpenAI API route. The ChatGPT/Codex subscription route is
309 // represented separately below as provider "openai-codex".
310 {
311 Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
312 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
313 PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
314 Speculative: true,
315 },
316 {
317 Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
318 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
319 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
320 Speculative: true,
321 },
322 {
323 Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
324 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
325 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
326 Speculative: true,
327 },
328 {
329 Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
330 ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
331 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
332 Speculative: true,
333 },
334 {
335 Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
336 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
337 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
338 Speculative: true,
339 },
340 {
341 Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
342 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
343 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
344 Speculative: true,
345 },
346 {
347 Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
348 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
349 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
350 Speculative: true,
351 },
352 {
353 Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
354 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
355 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
356 Speculative: true,
357 },
358 // ---- OpenAI Codex / ChatGPT subscription backend ----
359 // Same model ids as the OpenAI family, but routed through the
360 // ChatGPT Codex OAuth backend rather than api.openai.com.
361 {
362 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
363 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
364 PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
365 Speculative: true,
366 },
367 {
368 Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
369 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
370 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
371 Speculative: true,
372 },
373 {
374 Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
375 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
376 PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
377 Speculative: true,
378 },
379 {
380 Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
381 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
382 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
383 Speculative: true,
384 },
385 {
386 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
387 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
388 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
389 },
390 {
391 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
392 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
393 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
394 },
395 {
396 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
397 ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
398 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
399 },
400}
401
402// DefaultModel is used when the user does not specify one.
403var DefaultModel = Catalog[0] // claude-sonnet-4-5
404
405// ----- active (merged) catalog -----
406//
407// Callers should use Active() / FindModel / ModelsForProvider for
408// lookups. They return the baked-in Catalog merged with any live
409// models loaded via SetLiveModels.
410
411var (
412 activeMu sync.RWMutex
413 active []Model // live overlay merged in via SetLiveModels; nil = none yet
414 activeSet bool // true once SetLiveModels has run (even with empty live)
415 managedModels []Model // ephemeral models exposed by local model managers
416)
417
418// SetLiveModels replaces the "live" overlay used by the active catalog.
419// Typically called after a successful /v1/models discovery or on load
420// from the on-disk cache.
421func SetLiveModels(live []Model) {
422 SetLiveModelsForProviders(live, nil)
423}
424
425// SetLiveModelsForProviders replaces the live overlay, treating the listed
426// providers' live catalogs as authoritative. For those providers, static
427// entries omitted by discovery are removed from the active catalog.
428func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
429 activeMu.Lock()
430 defer activeMu.Unlock()
431 activeSet = true
432 if len(live) == 0 && len(authoritativeProviders) == 0 {
433 active = nil
434 return
435 }
436 active = MergeCatalogForProviders(live, authoritativeProviders)
437}
438
439// Active returns the current merged catalog.
440//
441// When no live overlay has been set it returns the fully-assembled
442// static Catalog. Reading Catalog here (rather than capturing it into
443// a package-level var initializer) is load-bearing: the extended
444// catalog in catalog_builtin.go / extra_models.go is appended from
445// init() functions, which run AFTER package-level var initializers.
446// Snapshotting Catalog at var-init time would freeze the picker to the
447// curated seed list and drop every extra provider (openrouter, groq,
448// xai, ...). Deferring the read to call time avoids that ordering trap.
449func Active() []Model {
450 activeMu.RLock()
451 defer activeMu.RUnlock()
452 src := active
453 if !activeSet || src == nil {
454 src = Catalog
455 }
456 out := make([]Model, len(src))
457 copy(out, src)
458 for i := range out {
459 out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
460 }
461 if len(managedModels) == 0 {
462 return out
463 }
464 index := make(map[string]int, len(out))
465 for i, model := range out {
466 index[model.Provider+"\x00"+model.ID] = i
467 }
468 for _, model := range managedModels {
469 model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
470 key := model.Provider + "\x00" + model.ID
471 if i, ok := index[key]; ok {
472 out[i] = model
473 continue
474 }
475 index[key] = len(out)
476 out = append(out, model)
477 }
478 return out
479}
480
481// SetManagedModels replaces the ephemeral catalog entries supplied by local
482// model managers. Unlike SetLiveModels, this does not disturb provider model
483// discovery or its on-disk cache.
484func SetManagedModels(models []Model) {
485 activeMu.Lock()
486 defer activeMu.Unlock()
487 managedModels = append([]Model(nil), models...)
488}
489
490// FindModel returns a Model by id, optionally constrained by provider.
491// If provider is empty, the first matching id is returned. Looks up
492// against the merged active catalog.
493func FindModel(provider, id string) (Model, error) {
494 for _, m := range Active() {
495 if m.ID == id && (provider == "" || m.Provider == provider) {
496 return m, nil
497 }
498 }
499 return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
500}
501
502// ModelsForProvider returns all models for the given provider, from the
503// merged active catalog.
504func ModelsForProvider(provider string) []Model {
505 var out []Model
506 for _, m := range Active() {
507 if m.Provider == provider {
508 out = append(out, m)
509 }
510 }
511 return out
512}
513
514// ComputeCost returns the USD cost for the given usage on model m.
515func ComputeCost(m Model, u Usage) float64 {
516 inputPrice := m.PriceInput
517 outputPrice := m.PriceOutput
518 cacheReadPrice := m.PriceCacheRead
519 cacheWritePrice := m.PriceCacheWrite
520 promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
521 if m.PriceTierInputTokens > 0 && promptTokens > m.PriceTierInputTokens {
522 inputPrice = m.PriceInputAbove
523 outputPrice = m.PriceOutputAbove
524 cacheReadPrice = m.PriceCacheReadAbove
525 cacheWritePrice = m.PriceCacheWriteAbove
526 }
527
528 const per = 1_000_000.0
529 return float64(u.InputTokens)*inputPrice/per +
530 float64(u.OutputTokens)*outputPrice/per +
531 float64(u.CacheReadTokens)*cacheReadPrice/per +
532 float64(u.CacheWriteTokens)*cacheWritePrice/per
533}