v0.44.1 22e42bf

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

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 477

↑↓ moveenter openesc close