v0.50.1 a637010

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	modelAvailability        = map[string]map[string]bool{} // account-specific, memory only
497)
498
499// CatalogSnapshot captures the mutable catalog overlays so a caller can
500// restore provider state after temporarily installing test or runtime data.
501// Use SnapshotCatalog and RestoreCatalog instead of replacing the live
502// overlay with nil, which can discard another caller's active catalog.
503type CatalogSnapshot struct {
504	active                   []Model
505	activeSet                bool
506	authoritativeProviderSet map[string]struct{}
507	managedModels            []Model
508	userModels               []Model
509	modelAvailability        map[string]map[string]bool
510}
511
512// SnapshotCatalog returns a deep copy of the mutable provider catalog state.
513func SnapshotCatalog() CatalogSnapshot {
514	activeMu.RLock()
515	defer activeMu.RUnlock()
516
517	snapshot := CatalogSnapshot{
518		activeSet:                activeSet,
519		authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
520		modelAvailability:        cloneModelAvailability(modelAvailability),
521	}
522	if active != nil {
523		snapshot.active = cloneModels(active)
524	}
525	if managedModels != nil {
526		snapshot.managedModels = cloneModels(managedModels)
527	}
528	if userModels != nil {
529		snapshot.userModels = cloneModels(userModels)
530	}
531	return snapshot
532}
533
534// RestoreCatalog restores a snapshot returned by SnapshotCatalog.
535func RestoreCatalog(snapshot CatalogSnapshot) {
536	activeMu.Lock()
537	defer activeMu.Unlock()
538
539	if snapshot.active != nil {
540		active = cloneModels(snapshot.active)
541	} else {
542		active = nil
543	}
544	activeSet = snapshot.activeSet
545	authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
546	modelAvailability = cloneModelAvailability(snapshot.modelAvailability)
547	if modelAvailability == nil {
548		modelAvailability = map[string]map[string]bool{}
549	}
550	if snapshot.managedModels != nil {
551		managedModels = cloneModels(snapshot.managedModels)
552	} else {
553		managedModels = nil
554	}
555	if snapshot.userModels != nil {
556		userModels = cloneModels(snapshot.userModels)
557	} else {
558		userModels = nil
559	}
560}
561
562func cloneModel(model Model) Model {
563	model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
564	model.ReasoningEffortMap = maps.Clone(model.ReasoningEffortMap)
565	if model.PriceTiers != nil {
566		model.PriceTiers = append([]ModelPriceTier(nil), model.PriceTiers...)
567	}
568	return model
569}
570
571func cloneModels(models []Model) []Model {
572	out := make([]Model, len(models))
573	for i, model := range models {
574		out[i] = cloneModel(model)
575	}
576	return out
577}
578
579func mergeUserModels(active, users []Model) []Model {
580	if len(users) == 0 {
581		return active
582	}
583	byKey := func(provider, id string) string { return provider + "\x00" + id }
584	index := make(map[string]int, len(active))
585	for i, model := range active {
586		index[byKey(model.Provider, model.ID)] = i
587	}
588	for _, user := range users {
589		user = cloneModel(user)
590		key := byKey(user.Provider, user.ID)
591		if idx, ok := index[key]; ok {
592			existing := active[idx]
593			// Preserve catalog values when an override leaves an optional
594			// field at its zero value, matching models.json semantics.
595			// A positive price override replaces tiered pricing too;
596			// otherwise ComputeCost would retain discovered tier rates.
597			if user.PriceInput > 0 || user.PriceOutput > 0 || user.PriceCacheRead > 0 || user.PriceCacheWrite > 0 {
598				existing.PriceTiers = nil
599				existing.PriceTierInputTokens = 0
600				existing.PriceInputAbove, existing.PriceOutputAbove = 0, 0
601				existing.PriceCacheReadAbove, existing.PriceCacheWriteAbove = 0, 0
602			}
603			if user.PriceInput > 0 {
604				existing.PriceInput = user.PriceInput
605			}
606			if user.PriceOutput > 0 {
607				existing.PriceOutput = user.PriceOutput
608			}
609			if user.PriceCacheRead > 0 {
610				existing.PriceCacheRead = user.PriceCacheRead
611			}
612			if user.PriceCacheWrite > 0 {
613				existing.PriceCacheWrite = user.PriceCacheWrite
614			}
615			if user.DisplayName != "" {
616				existing.DisplayName = user.DisplayName
617			}
618			if user.ContextWindow > 0 {
619				existing.ContextWindow = user.ContextWindow
620			}
621			if user.MaxOutput > 0 {
622				existing.MaxOutput = user.MaxOutput
623			}
624			existing.Reasoning = user.Reasoning
625			if user.ReasoningLevelMap != nil {
626				existing.ReasoningLevelMap = user.ReasoningLevelMap
627			}
628			if user.ReasoningEffortMap != nil {
629				existing.ReasoningEffortMap = user.ReasoningEffortMap
630			}
631			if user.API != "" {
632				existing.API = user.API
633			}
634			if user.BaseURL != "" {
635				existing.BaseURL = user.BaseURL
636			}
637			existing.Source = "user"
638			existing.Speculative = false
639			if existing.Provider == ProviderGitHubCopilot {
640				configureCopilotModel(&existing) // normalizes a legacy api value
641			}
642			active[idx] = existing
643			continue
644		}
645		if user.Provider == ProviderGitHubCopilot {
646			// A user-only Copilot model has no catalog row, so it would miss the
647			// protocol-specific defaults built-in rows receive. Explicit API and
648			// reasoning mappings from models.json are preserved.
649			configureCopilotModel(&user)
650		}
651		index[key] = len(active)
652		active = append(active, user)
653	}
654	return active
655}
656
657// SetLiveModels replaces the "live" overlay used by the active catalog.
658// Typically called after a successful /v1/models discovery or on load
659// from the on-disk cache.
660func SetLiveModels(live []Model) {
661	SetLiveModelsForProviders(live, nil)
662}
663
664// SetLiveModelsForProviders replaces the live overlay, treating the listed
665// providers' live catalogs as authoritative. For those providers, static
666// entries omitted by discovery are removed from the active catalog.
667func SetLiveModelsForProviders(live []Model, authoritativeProviders []string) {
668	activeMu.Lock()
669	defer activeMu.Unlock()
670	activeSet = true
671	authoritativeProviderSet = make(map[string]struct{}, len(authoritativeProviders))
672	for _, name := range authoritativeProviders {
673		authoritativeProviderSet[name] = struct{}{}
674	}
675	if len(live) == 0 && len(authoritativeProviders) == 0 {
676		active = nil
677		return
678	}
679	active = MergeCatalogForProviders(live, authoritativeProviders)
680}
681
682// ClearLiveModelsForProvider removes a provider's live overlay without
683// disturbing other providers. User models remain separate and continue to
684// be applied by Active.
685func ClearLiveModelsForProvider(name string) {
686	activeMu.Lock()
687	defer activeMu.Unlock()
688	delete(authoritativeProviderSet, name)
689	if !activeSet || len(active) == 0 {
690		return
691	}
692	out := active[:0]
693	for _, model := range active {
694		if model.Provider != name {
695			out = append(out, model)
696		}
697	}
698	active = out
699}
700
701// Active returns the current merged catalog.
702//
703// When no live overlay has been set it returns the fully-assembled
704// static Catalog. Reading Catalog here (rather than capturing it into
705// a package-level var initializer) is load-bearing: the extended
706// catalog in catalog_builtin.go / extra_models.go is appended from
707// init() functions, which run AFTER package-level var initializers.
708// Snapshotting Catalog at var-init time would freeze the picker to the
709// curated seed list and drop every extra provider (openrouter, groq,
710// xai, ...). Deferring the read to call time avoids that ordering trap.
711func Active() []Model { return activeModels(true) }
712
713// activeModels builds the merged catalog. When availableOnly is set, providers
714// with an account availability snapshot expose only the models it lists.
715func activeModels(availableOnly bool) []Model {
716	activeMu.RLock()
717	defer activeMu.RUnlock()
718	src := active
719	if !activeSet || src == nil {
720		src = Catalog
721	}
722	out := cloneModels(src)
723	if len(managedModels) > 0 {
724		index := make(map[string]int, len(out))
725		for i, model := range out {
726			index[model.Provider+"\x00"+model.ID] = i
727		}
728		for _, model := range managedModels {
729			model = cloneModel(model)
730			key := model.Provider + "\x00" + model.ID
731			if i, ok := index[key]; ok {
732				out[i] = model
733				continue
734			}
735			index[key] = len(out)
736			out = append(out, model)
737		}
738	}
739	// User models are a durable, highest-precedence overlay. Apply them
740	// after the live and managed overlays so a catalog refresh cannot
741	// discard overrides or user-only entries.
742	out = mergeUserModels(out, userModels)
743	if availableOnly {
744		out = filterAvailableModels(out)
745	}
746	return out
747}
748
749func cloneModelAvailability(in map[string]map[string]bool) map[string]map[string]bool {
750	if in == nil {
751		return nil
752	}
753	out := make(map[string]map[string]bool, len(in))
754	for name, ids := range in {
755		out[name] = maps.Clone(ids)
756	}
757	return out
758}
759
760// SetModelAvailability restricts a provider's visible catalog to the supplied
761// account model IDs. A nil slice clears the restriction; an empty non-nil slice
762// hides all of the provider's models. The snapshot is memory-only: it is never
763// written to the shared model cache because it is account-specific.
764func SetModelAvailability(provider string, ids []string) {
765	activeMu.Lock()
766	defer activeMu.Unlock()
767	if ids == nil {
768		delete(modelAvailability, provider)
769		return
770	}
771	allowed := make(map[string]bool, len(ids))
772	for _, id := range ids {
773		allowed[id] = true
774	}
775	modelAvailability[provider] = allowed
776}
777
778// filterAvailableModels removes models hidden by account availability. The
779// caller holds activeMu and owns models.
780func filterAvailableModels(models []Model) []Model {
781	if len(modelAvailability) == 0 {
782		return models
783	}
784	out := models[:0]
785	for _, m := range models {
786		allowed, restricted := modelAvailability[m.Provider]
787		if !restricted || allowed[m.ID] {
788			out = append(out, m)
789		}
790	}
791	return out
792}
793
794// SetManagedModels replaces the ephemeral catalog entries supplied by local
795// model managers. Unlike SetLiveModels, this does not disturb provider model
796// discovery or its on-disk cache.
797func SetManagedModels(models []Model) {
798	activeMu.Lock()
799	defer activeMu.Unlock()
800	managedModels = append([]Model(nil), models...)
801}
802
803// SetManagedModelsForProvider replaces only one provider's managed entries,
804// leaving other managers' snapshots (llama.cpp, custom discovery, ...) intact.
805// Entries whose Provider differs from providerID are ignored.
806func SetManagedModelsForProvider(providerID string, models []Model) {
807	activeMu.Lock()
808	defer activeMu.Unlock()
809	kept := make([]Model, 0, len(managedModels)+len(models))
810	for _, m := range managedModels {
811		if m.Provider != providerID {
812			kept = append(kept, m)
813		}
814	}
815	for _, m := range models {
816		if m.Provider == providerID {
817			kept = append(kept, cloneModel(m))
818		}
819	}
820	managedModels = kept
821}
822
823// FindModel returns a Model by id, optionally constrained by provider.
824// If provider is empty, the first matching id is returned. Looks up
825// against the merged catalog, including models hidden by account availability:
826// existing sessions still need stable wire and pricing metadata after a refresh.
827func FindModel(provider, id string) (Model, error) {
828	for _, m := range activeModels(false) {
829		if m.ID == id && (provider == "" || m.Provider == provider) {
830			return m, nil
831		}
832	}
833	return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider)
834}
835
836// IsProviderCatalogAuthoritative reports whether a successful live catalog
837// has declared the provider's available model IDs authoritative.
838func IsProviderCatalogAuthoritative(provider string) bool {
839	activeMu.RLock()
840	defer activeMu.RUnlock()
841	_, ok := authoritativeProviderSet[provider]
842	return ok
843}
844
845// AcceptsUnlistedModels reports whether a provider may be queried with an
846// ID that is not in the local catalog. OpenCode Go permits this only before
847// a successful authoritative live catalog has been loaded.
848func AcceptsUnlistedModels(provider string) bool {
849	return provider == ProviderOpenCodeGo && !IsProviderCatalogAuthoritative(provider)
850}
851
852// ModelsForProvider returns all models for the given provider, from the
853// merged active catalog.
854func ModelsForProvider(provider string) []Model {
855	var out []Model
856	for _, m := range Active() {
857		if m.Provider == provider {
858			out = append(out, m)
859		}
860	}
861	return out
862}
863
864// ComputeCost returns the USD cost for the given usage on model m.
865func ComputeCost(m Model, u Usage) float64 {
866	inputPrice := m.PriceInput
867	outputPrice := m.PriceOutput
868	cacheReadPrice := m.PriceCacheRead
869	cacheWritePrice := m.PriceCacheWrite
870	promptTokens := u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
871	if len(m.PriceTiers) > 0 {
872		selected := -1
873		for i, tier := range m.PriceTiers {
874			if tier.InputTokens <= promptTokens && (selected < 0 || tier.InputTokens > m.PriceTiers[selected].InputTokens) {
875				selected = i
876			}
877		}
878		if selected >= 0 {
879			tier := m.PriceTiers[selected]
880			inputPrice = tier.PriceInput
881			outputPrice = tier.PriceOutput
882			cacheReadPrice = tier.PriceCacheRead
883			cacheWritePrice = tier.PriceCacheWrite
884		}
885	} else if m.PriceTierInputTokens > 0 && promptTokens >= m.PriceTierInputTokens {
886		// Keep the legacy single-tier fields inclusive at the threshold.
887		inputPrice = m.PriceInputAbove
888		outputPrice = m.PriceOutputAbove
889		cacheReadPrice = m.PriceCacheReadAbove
890		cacheWritePrice = m.PriceCacheWriteAbove
891	}
892
893	const per = 1_000_000.0
894	return float64(u.InputTokens)*inputPrice/per +
895		float64(u.OutputTokens)*outputPrice/per +
896		float64(u.CacheReadTokens)*cacheReadPrice/per +
897		float64(u.CacheWriteTokens)*cacheWritePrice/per
898}

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