v0.38.0 58133ea

models.go RawHistoryFolder

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-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
312		ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
313		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
314		Speculative: true,
315	},
316	{
317		Provider: "anthropic", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
318		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
319		PriceInput: 2, PriceOutput: 10, PriceCacheRead: 0.2, PriceCacheWrite: 2.5,
320	},
321	{
322		Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
323		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
324		PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
325		Speculative: true,
326	},
327
328	// ---- Speculative: OpenAI ----
329	// Public OpenAI API route. The ChatGPT/Codex subscription route is
330	// represented separately below as provider "openai-codex".
331	{
332		Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
333		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
334		PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
335		Speculative: true,
336	},
337	{
338		Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
339		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
340		PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
341		Speculative: true,
342	},
343	{
344		Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
345		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
346		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
347		Speculative: true,
348	},
349	{
350		Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
351		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
352		PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
353		Speculative: true,
354	},
355	{
356		Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
357		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
358		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
359		Speculative: true,
360	},
361	{
362		Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
363		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
364		PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
365		Speculative: true,
366	},
367	{
368		Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
369		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
370		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
371		Speculative: true,
372	},
373	{
374		Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
375		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
376		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
377		Speculative: true,
378	},
379	{
380		Provider: "openai", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses,
381		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
382		PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5,
383		PriceTierInputTokens: 272000,
384		PriceInputAbove:      20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25,
385	},
386	// ---- OpenAI Codex / ChatGPT subscription backend ----
387	// Same model ids as the OpenAI family, but routed through the
388	// ChatGPT Codex OAuth backend rather than api.openai.com.
389	{
390		Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
391		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
392		PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
393		Speculative: true,
394	},
395	{
396		Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
397		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
398		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
399		Speculative: true,
400	},
401	{
402		Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
403		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
404		PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
405		Speculative: true,
406	},
407	{
408		Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
409		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
410		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
411		Speculative: true,
412	},
413	{
414		Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
415		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
416		PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
417	},
418	{
419		Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
420		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
421		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
422	},
423	{
424		Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
425		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
426		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
427	},
428	{
429		Provider: "openai-codex", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra",
430		ContextWindow: openAIContextWindowTarget, MaxOutput: 128000, Reasoning: true,
431		PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5,
432		PriceTierInputTokens: 272000,
433		PriceInputAbove:      20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25,
434	},
435}
436
437// DefaultModel is used when the user does not specify one.
438var DefaultModel = Catalog[0] // claude-sonnet-4-5
439
440// ----- active (merged) catalog -----
441//
442// Callers should use Active() / FindModel / ModelsForProvider for
443// lookups. They return the baked-in Catalog merged with any live
444// models loaded via SetLiveModels.
445
446var (
447	activeMu                 sync.RWMutex
448	active                   []Model // live overlay merged in via SetLiveModels; nil = none yet
449	activeSet                bool    // true once SetLiveModels has run (even with empty live)
450	authoritativeProviderSet map[string]struct{}
451	managedModels            []Model // ephemeral models exposed by local model managers
452	userModels               []Model // highest-precedence models loaded from models.json
453)
454
455// CatalogSnapshot captures the mutable catalog overlays so a caller can
456// restore provider state after temporarily installing test or runtime data.
457// Use SnapshotCatalog and RestoreCatalog instead of replacing the live
458// overlay with nil, which can discard another caller's active catalog.
459type CatalogSnapshot struct {
460	active                   []Model
461	activeSet                bool
462	authoritativeProviderSet map[string]struct{}
463	managedModels            []Model
464	userModels               []Model
465}
466
467// SnapshotCatalog returns a deep copy of the mutable provider catalog state.
468func SnapshotCatalog() CatalogSnapshot {
469	activeMu.RLock()
470	defer activeMu.RUnlock()
471
472	snapshot := CatalogSnapshot{
473		activeSet:                activeSet,
474		authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
475	}
476	if active != nil {
477		snapshot.active = cloneModels(active)
478	}
479	if managedModels != nil {
480		snapshot.managedModels = cloneModels(managedModels)
481	}
482	if userModels != nil {
483		snapshot.userModels = cloneModels(userModels)
484	}
485	return snapshot
486}
487
488// RestoreCatalog restores a snapshot returned by SnapshotCatalog.
489func RestoreCatalog(snapshot CatalogSnapshot) {
490	activeMu.Lock()
491	defer activeMu.Unlock()
492
493	if snapshot.active != nil {
494		active = cloneModels(snapshot.active)
495	} else {
496		active = nil
497	}
498	activeSet = snapshot.activeSet
499	authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
500	if snapshot.managedModels != nil {
501		managedModels = cloneModels(snapshot.managedModels)
502	} else {
503		managedModels = nil
504	}
505	if snapshot.userModels != nil {
506		userModels = cloneModels(snapshot.userModels)
507	} else {
508		userModels = nil
509	}
510}
511
512func cloneModel(model Model) Model {
513	model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
514	model.ReasoningEffortMap = maps.Clone(model.ReasoningEffortMap)
515	if model.PriceTiers != nil {
516		model.PriceTiers = append([]ModelPriceTier(nil), model.PriceTiers...)
517	}
518	return model
519}
520
521func cloneModels(models []Model) []Model {
522	out := make([]Model, len(models))
523	for i, model := range models {
524		out[i] = cloneModel(model)
525	}
526	return out
527}
528
529func mergeUserModels(active, users []Model) []Model {
530	if len(users) == 0 {
531		return active
532	}
533	byKey := func(provider, id string) string { return provider + "\x00" + id }
534	index := make(map[string]int, len(active))
535	for i, model := range active {
536		index[byKey(model.Provider, model.ID)] = i
537	}
538	for _, user := range users {
539		user = cloneModel(user)
540		key := byKey(user.Provider, user.ID)
541		if idx, ok := index[key]; ok {
542			existing := active[idx]
543			// Preserve catalog values when an override leaves an optional
544			// field at its zero value, matching models.json semantics.
545			// A positive price override replaces tiered pricing too;
546			// otherwise ComputeCost would retain discovered tier rates.
547			if user.PriceInput > 0 || user.PriceOutput > 0 || user.PriceCacheRead > 0 || user.PriceCacheWrite > 0 {
548				existing.PriceTiers = nil
549				existing.PriceTierInputTokens = 0
550				existing.PriceInputAbove, existing.PriceOutputAbove = 0, 0
551				existing.PriceCacheReadAbove, existing.PriceCacheWriteAbove = 0, 0
552			}
553			if user.PriceInput > 0 {
554				existing.PriceInput = user.PriceInput
555			}
556			if user.PriceOutput > 0 {
557				existing.PriceOutput = user.PriceOutput
558			}
559			if user.PriceCacheRead > 0 {
560				existing.PriceCacheRead = user.PriceCacheRead
561			}
562			if user.PriceCacheWrite > 0 {
563				existing.PriceCacheWrite = user.PriceCacheWrite
564			}
565			if user.DisplayName != "" {
566				existing.DisplayName = user.DisplayName
567			}
568			if user.ContextWindow > 0 {
569				existing.ContextWindow = user.ContextWindow
570			}
571			if user.MaxOutput > 0 {
572				existing.MaxOutput = user.MaxOutput
573			}
574			existing.Reasoning = user.Reasoning
575			if user.ReasoningLevelMap != nil {
576				existing.ReasoningLevelMap = user.ReasoningLevelMap
577			}
578			if user.ReasoningEffortMap != nil {
579				existing.ReasoningEffortMap = user.ReasoningEffortMap
580			}
581			if user.API != "" {
582				existing.API = user.API
583			}
584			if user.BaseURL != "" {
585				existing.BaseURL = user.BaseURL
586			}
587			existing.Source = "user"
588			existing.Speculative = false
589			active[idx] = existing
590			continue
591		}
592		index[key] = len(active)
593		active = append(active, user)
594	}
595	return active
596}
597
598// SetLiveModels replaces the "live" overlay used by the active catalog.
599// Typically called after a successful /v1/models discovery or on load
600// from the on-disk cache.
601func SetLiveModels(live []Model) {
602	SetLiveModelsForProviders(live, nil)
603}
604
605// SetLiveModelsForProviders replaces the live overlay, treating the listed
606// providers' live catalogs as authoritative. For those providers, static
607// entries omitted by discovery are removed from the active catalog.
608func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
609	activeMu.Lock()
610	defer activeMu.Unlock()
611	activeSet = true
612	authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders))
613	for _, name := range authoritativeProviders {
614		authoritativeProviderSet[name] = struct{}{}
615	}
616	if len(live) == 0 && len(authoritativeProviders) == 0 {
617		active = nil
618		return
619	}
620	active = MergeCatalogForProviders(live, authoritativeProviders)
621}
622
623// ClearLiveModelsForProvider removes a provider's live overlay without
624// disturbing other providers. User models remain separate and continue to
625// be applied by Active.
626func ClearLiveModelsForProvider(name string) {
627	activeMu.Lock()
628	defer activeMu.Unlock()
629	delete(authoritativeProviderSet, name)
630	if !activeSet || len(active) == 0 {
631		return
632	}
633	out := active[:0]
634	for _, model := range active {
635		if model.Provider != name {
636			out = append(out, model)
637		}
638	}
639	active = out
640}
641
642// Active returns the current merged catalog.
643//
644// When no live overlay has been set it returns the fully-assembled
645// static Catalog. Reading Catalog here (rather than capturing it into
646// a package-level var initializer) is load-bearing: the extended
647// catalog in catalog_builtin.go / extra_models.go is appended from
648// init() functions, which run AFTER package-level var initializers.
649// Snapshotting Catalog at var-init time would freeze the picker to the
650// curated seed list and drop every extra provider (openrouter, groq,
651// xai, ...). Deferring the read to call time avoids that ordering trap.
652func Active() []Model {
653	activeMu.RLock()
654	defer activeMu.RUnlock()
655	src := active
656	if !activeSet || src == nil {
657		src = Catalog
658	}
659	out := cloneModels(src)
660	if len(managedModels) > 0 {
661		index := make(map[string]int, len(out))
662		for i, model := range out {
663			index[model.Provider+"\x00"+model.ID] = i
664		}
665		for _, model := range managedModels {
666			model = cloneModel(model)
667			key := model.Provider + "\x00" + model.ID
668			if i, ok := index[key]; ok {
669				out[i] = model
670				continue
671			}
672			index[key] = len(out)
673			out = append(out, model)
674		}
675	}
676	// User models are a durable, highest-precedence overlay. Apply them
677	// after the live and managed overlays so a catalog refresh cannot
678	// discard overrides or user-only entries.
679	return mergeUserModels(out, userModels)
680}
681
682// SetManagedModels replaces the ephemeral catalog entries supplied by local
683// model managers. Unlike SetLiveModels, this does not disturb provider model
684// discovery or its on-disk cache.
685func SetManagedModels(models []Model) {
686	activeMu.Lock()
687	defer activeMu.Unlock()
688	managedModels = append([]Model(nil), models...)
689}
690
691// FindModel returns a Model by id, optionally constrained by provider.
692// If provider is empty, the first matching id is returned. Looks up
693// against the merged active catalog.
694func FindModel(provider, id string) (Model, error) {
695	for _, m := range Active() {
696		if m.ID == id && (provider == "" || m.Provider == provider) {
697			return m, nil
698		}
699	}
700	return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
701}
702
703// IsProviderCatalogAuthoritative reports whether a successful live catalog
704// has declared the provider's available model IDs authoritative.
705func IsProviderCatalogAuthoritative(provider string) bool {
706	activeMu.RLock()
707	defer activeMu.RUnlock()
708	_, ok := authoritativeProviderSet[provider]
709	return ok
710}
711
712// AcceptsUnlistedModels reports whether a provider may be queried with an
713// ID that is not in the local catalog. OpenCode Go permits this only before
714// a successful authoritative live catalog has been loaded.
715func AcceptsUnlistedModels(provider string) bool {
716	return provider == ProviderOpenCodeGo && !IsProviderCatalogAuthoritative(provider)
717}
718
719// ModelsForProvider returns all models for the given provider, from the
720// merged active catalog.
721func ModelsForProvider(provider string) []Model {
722	var out []Model
723	for _, m := range Active() {
724		if m.Provider == provider {
725			out = append(out, m)
726		}
727	}
728	return out
729}
730
731// ComputeCost returns the USD cost for the given usage on model m.
732func ComputeCost(m Model, u Usage) float64 {
733	inputPrice := m.PriceInput
734	outputPrice := m.PriceOutput
735	cacheReadPrice := m.PriceCacheRead
736	cacheWritePrice := m.PriceCacheWrite
737	promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
738	if len(m.PriceTiers) > 0 {
739		selected := -1
740		for i, tier := range m.PriceTiers {
741			if tier.InputTokens <= promptTokens && (selected < 0 || tier.InputTokens > m.PriceTiers[selected].InputTokens) {
742				selected = i
743			}
744		}
745		if selected >= 0 {
746			tier := m.PriceTiers[selected]
747			inputPrice = tier.PriceInput
748			outputPrice = tier.PriceOutput
749			cacheReadPrice = tier.PriceCacheRead
750			cacheWritePrice = tier.PriceCacheWrite
751		}
752	} else if m.PriceTierInputTokens > 0 && promptTokens >= m.PriceTierInputTokens {
753		// Keep the legacy single-tier fields inclusive at the threshold.
754		inputPrice = m.PriceInputAbove
755		outputPrice = m.PriceOutputAbove
756		cacheReadPrice = m.PriceCacheReadAbove
757		cacheWritePrice = m.PriceCacheWriteAbove
758	}
759
760	const per = 1_000_000.0
761	return float64(u.InputTokens)*inputPrice/per +
762		float64(u.OutputTokens)*outputPrice/per +
763		float64(u.CacheReadTokens)*cacheReadPrice/per +
764		float64(u.CacheWriteTokens)*cacheWritePrice/per
765}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close