v0.47.0 1ddba1e

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

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