Skip to main content

agent_sdk_providers/
model_capabilities.rs

1use agent_sdk_foundation::llm::Usage;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SourceStatus {
5    Official,
6    Derived,
7    Unverified,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct PricePoint {
12    /// USD per 1M tokens.
13    pub usd_per_million_tokens: f64,
14}
15
16impl PricePoint {
17    #[must_use]
18    pub const fn new(usd_per_million_tokens: f64) -> Self {
19        Self {
20            usd_per_million_tokens,
21        }
22    }
23
24    #[must_use]
25    pub fn estimate_cost_usd(self, tokens: u32) -> f64 {
26        (f64::from(tokens) / 1_000_000.0) * self.usd_per_million_tokens
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct Pricing {
32    pub input: Option<PricePoint>,
33    pub output: Option<PricePoint>,
34    /// Rate for input tokens served from cache (`cache_read`).
35    pub cached_input: Option<PricePoint>,
36    /// Rate for input tokens written INTO the cache (`cache_write`).
37    ///
38    /// Providers charge a premium for a cache write — Anthropic's is 1.25× the
39    /// ordinary input rate — so a source that publishes one prices those
40    /// tokens with it. `None` means the source does not publish one, and
41    /// cache-creation tokens then bill at the ordinary input rate, which is a
42    /// bounded under-estimate of what the provider actually charges.
43    pub cache_write: Option<PricePoint>,
44    /// Rate a source publishes for reasoning/thinking tokens, when it differs
45    /// from the output rate.
46    ///
47    /// Reasoning tokens ride *inside* the output-token count — no [`Usage`]
48    /// field splits them out — so they cannot be billed separately. Output is
49    /// therefore billed at `max(output, reasoning)`: on the many rows where
50    /// reasoning is cheaper this is the output rate (unchanged), and on the
51    /// rows where reasoning is dearer (e.g. `alibaba/qwen3-32b`: output $2.80/M,
52    /// reasoning $8.40/M) it lifts the whole output band to the reasoning rate,
53    /// so the estimate is never below what the reasoning tokens actually cost.
54    /// The exact split needs a reasoning component on [`Usage`]; see the PR
55    /// residual.
56    pub reasoning: Option<PricePoint>,
57    pub notes: Option<&'static str>,
58}
59
60/// The three bands an input-token count splits into for billing, given a
61/// [`Usage`]. Cache-creation and cache-read counts are components of
62/// `input_tokens` for every provider the SDK supports, so the plain band is
63/// what is left after both are taken out.
64struct InputBands {
65    plain: u32,
66    cache_read: u32,
67    cache_write: u32,
68}
69
70impl InputBands {
71    fn split(usage: &Usage) -> Self {
72        let cache_read = usage.cached_input_tokens.min(usage.input_tokens);
73        let remaining = usage.input_tokens.saturating_sub(cache_read);
74        let cache_write = usage.cache_creation_input_tokens.min(remaining);
75        Self {
76            plain: remaining.saturating_sub(cache_write),
77            cache_read,
78            cache_write,
79        }
80    }
81}
82
83impl Pricing {
84    #[must_use]
85    pub const fn flat(input: f64, output: f64) -> Self {
86        Self {
87            input: Some(PricePoint::new(input)),
88            output: Some(PricePoint::new(output)),
89            cached_input: None,
90            cache_write: None,
91            reasoning: None,
92            notes: None,
93        }
94    }
95
96    #[must_use]
97    pub const fn flat_with_cached(input: f64, output: f64, cached_input: f64) -> Self {
98        Self {
99            input: Some(PricePoint::new(input)),
100            output: Some(PricePoint::new(output)),
101            cached_input: Some(PricePoint::new(cached_input)),
102            cache_write: None,
103            reasoning: None,
104            notes: None,
105        }
106    }
107
108    /// Builder-style: attach the cache-write (`cache_write`) rate.
109    #[must_use]
110    pub const fn with_cache_write(mut self, cache_write: f64) -> Self {
111        self.cache_write = Some(PricePoint::new(cache_write));
112        self
113    }
114
115    /// Builder-style: attach the reasoning-token rate.
116    #[must_use]
117    pub const fn with_reasoning(mut self, reasoning: f64) -> Self {
118        self.reasoning = Some(PricePoint::new(reasoning));
119        self
120    }
121
122    #[must_use]
123    pub const fn with_notes(mut self, notes: &'static str) -> Self {
124        self.notes = Some(notes);
125        self
126    }
127
128    /// The rate each input band is billed at, falling back to the ordinary
129    /// input rate for a band whose own rate this source does not publish.
130    ///
131    /// A cache read or a cache write is still an input token: pricing it at
132    /// the plain input rate is the provider-agnostic approximation, and it is
133    /// what the compiled-in table (which carries no cache rates for most
134    /// models) has always done.
135    const fn band_rate(&self, band: Option<PricePoint>) -> Option<PricePoint> {
136        match band {
137            Some(rate) => Some(rate),
138            None => self.input,
139        }
140    }
141
142    #[must_use]
143    pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
144        let bands = InputBands::split(usage);
145
146        let mut input_cost: Option<f64> = None;
147        let mut add = |rate: Option<PricePoint>, tokens: u32| {
148            if let Some(rate) = rate {
149                *input_cost.get_or_insert(0.0) += rate.estimate_cost_usd(tokens);
150            }
151        };
152        add(self.input, bands.plain);
153        add(self.band_rate(self.cached_input), bands.cache_read);
154        add(self.band_rate(self.cache_write), bands.cache_write);
155
156        // Reasoning tokens ride inside `output_tokens` with no field to split
157        // them out, so the whole output band bills at the higher of the output
158        // and reasoning rates — see [`Pricing::reasoning`]. On a row with no
159        // reasoning rate, or one where reasoning is cheaper, this is just the
160        // output rate; where reasoning is dearer it lifts the band so the
161        // estimate never falls below the reasoning tokens' true cost.
162        let output = output_rate(self.output, self.reasoning)
163            .map(|p| p.estimate_cost_usd(usage.output_tokens));
164        match (input_cost, output) {
165            (Some(input), Some(output)) => Some(input + output),
166            (Some(input), None) => Some(input),
167            (None, Some(output)) => Some(output),
168            (None, None) => None,
169        }
170    }
171}
172
173/// The rate the output band bills at: the more expensive of the output and
174/// reasoning rates when both are known, since reasoning tokens are billed
175/// inside the output count and must never be under-priced.
176const fn output_rate(
177    output: Option<PricePoint>,
178    reasoning: Option<PricePoint>,
179) -> Option<PricePoint> {
180    match (output, reasoning) {
181        (Some(output), Some(reasoning)) => {
182            if reasoning.usd_per_million_tokens > output.usd_per_million_tokens {
183                Some(reasoning)
184            } else {
185                Some(output)
186            }
187        }
188        (rate, None) | (None, rate) => rate,
189    }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct ModelCapabilities {
194    pub provider: &'static str,
195    pub model_id: &'static str,
196    pub context_window: Option<u32>,
197    pub max_output_tokens: Option<u32>,
198    pub pricing: Option<Pricing>,
199    pub supports_thinking: bool,
200    pub supports_adaptive_thinking: bool,
201    /// The model rejects `ThinkingMode::Enabled { budget_tokens }` (the
202    /// API returns 400). Adaptive thinking and provider-default thinking
203    /// (a bare effort via `ThinkingConfig::default_with_effort`, no
204    /// `thinking` object) both remain valid — the model does NOT require
205    /// an adaptive request. Callers holding a budget-mode preference must
206    /// switch to one of those shapes before dispatch or the provider
207    /// fails the request in `validate_thinking_config`.
208    pub rejects_budget_thinking: bool,
209    pub source_url: &'static str,
210    pub source_status: SourceStatus,
211    pub notes: Option<&'static str>,
212}
213
214impl ModelCapabilities {
215    #[must_use]
216    pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
217        self.pricing
218            .as_ref()
219            .and_then(|p| p.estimate_cost_usd(usage))
220    }
221}
222
223const ANTHROPIC_MODELS_URL: &str =
224    "https://docs.anthropic.com/en/docs/about-claude/models/all-models";
225const OPENAI_MODELS_URL: &str = "https://developers.openai.com/api/docs/models";
226const OPENAI_PRICING_URL: &str = "https://developers.openai.com/api/docs/pricing";
227const OPENAI_GPT56_SOL_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-sol";
228const OPENAI_GPT56_TERRA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-terra";
229const OPENAI_GPT56_LUNA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-luna";
230const OPENAI_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
231const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
232const OPENAI_GPT52_PRO_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.2-pro";
233const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
234const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
235
236// Open-model routes. All reached through OpenAIProvider (provider()=="openai"),
237// whether via OpenRouter slugs or the native z.ai / Moonshot / MiniMax base URLs.
238const OPENROUTER_GLM51_URL: &str = "https://openrouter.ai/z-ai/glm-5.1";
239const ZAI_GLM5_PRICING_URL: &str = "https://docs.z.ai/guides/overview/pricing";
240const OPENROUTER_KIMI_K26_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.6";
241const OPENROUTER_KIMI_K25_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.5";
242const KIMI_K25_AA_URL: &str = "https://artificialanalysis.ai/models/kimi-k2-5";
243const OPENROUTER_KIMI_K2_THINKING_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2-thinking";
244const OPENROUTER_DEEPSEEK_V4_PRO_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-pro";
245const OPENROUTER_DEEPSEEK_V4_FLASH_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-flash";
246const DEEPSEEK_PRICING_URL: &str = "https://api-docs.deepseek.com/quick_start/pricing";
247const MINIMAX_PRICING_URL: &str = "https://platform.minimax.io/docs/guides/pricing-paygo";
248const OPENROUTER_MINIMAX_M25_URL: &str = "https://openrouter.ai/minimax/minimax-m2.5";
249
250const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
251    // Anthropic
252    ModelCapabilities {
253        provider: "anthropic",
254        model_id: "claude-fable-5",
255        context_window: Some(1_000_000),
256        max_output_tokens: Some(128_000),
257        pricing: Some(Pricing::flat(10.0, 50.0).with_notes("Anthropic Fable 5 official pricing: $10 input / $50 output per 1M tokens.")),
258        supports_thinking: true,
259        supports_adaptive_thinking: true,
260        rejects_budget_thinking: true,
261        source_url: ANTHROPIC_MODELS_URL,
262        source_status: SourceStatus::Official,
263        notes: Some("Fable 5 is adaptive-only: adaptive thinking is always on (applies even when `thinking` is unset) and `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config. Raw chain of thought is never returned — thinking blocks arrive empty (the SDK requests thinking display=omitted). Safety classifiers may decline a request with stop_reason=refusal on an HTTP 200."),
264    },
265    ModelCapabilities {
266        provider: "anthropic",
267        model_id: "claude-opus-5",
268        context_window: Some(1_000_000),
269        max_output_tokens: Some(128_000),
270        pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 5 official pricing: $5 input / $25 output per 1M tokens. Fast mode (`speed: \"fast\"`, not implemented by this SDK) is billed at $10/$50. Uses the Opus 4.7 tokenizer, which produces ~30% more tokens than Opus 4.6 and earlier for the same text.")),
271        supports_thinking: true,
272        supports_adaptive_thinking: true,
273        rejects_budget_thinking: true,
274        source_url: ANTHROPIC_MODELS_URL,
275        source_status: SourceStatus::Official,
276        notes: Some("Opus 5 rejects budget thinking — extended thinking (`thinking.type: \"enabled\"`) is not offered, so `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API (the SDK fails fast in validate_thinking_config). Adaptive is supported but optional: an effort level can be sent without it via `ThinkingConfig::default_with_effort`. When effort is unset the Claude API defaults to `high`."),
277    },
278    ModelCapabilities {
279        provider: "anthropic",
280        model_id: "claude-opus-4-8",
281        context_window: Some(1_000_000),
282        max_output_tokens: Some(128_000),
283        pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 4.8 pricing matches the Opus 4.6 tier ($5/$25 per 1M); verify exact current SKU mapping before billing-critical use.")),
284        supports_thinking: true,
285        supports_adaptive_thinking: true,
286        rejects_budget_thinking: true,
287        source_url: ANTHROPIC_MODELS_URL,
288        source_status: SourceStatus::Derived,
289        notes: Some("Opus 4.8 rejects budget thinking — `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API (the SDK fails fast in validate_thinking_config). Adaptive is supported but optional: an effort level can be sent without it via `ThinkingConfig::default_with_effort`."),
290    },
291    ModelCapabilities {
292        provider: "anthropic",
293        model_id: "claude-opus-4-7",
294        context_window: Some(1_000_000),
295        max_output_tokens: Some(128_000),
296        pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 4.7 pricing matches the Opus 4.6 tier ($5/$25 per 1M); verify exact current SKU mapping before billing-critical use.")),
297        supports_thinking: true,
298        supports_adaptive_thinking: true,
299        rejects_budget_thinking: true,
300        source_url: ANTHROPIC_MODELS_URL,
301        source_status: SourceStatus::Derived,
302        notes: Some("Opus 4.7 rejects budget thinking — `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API (the SDK fails fast in validate_thinking_config). Adaptive is supported but optional: an effort level can be sent without it via `ThinkingConfig::default_with_effort`."),
303    },
304    ModelCapabilities {
305        provider: "anthropic",
306        model_id: "claude-opus-4-6",
307        context_window: Some(1_000_000),
308        max_output_tokens: Some(128_000),
309        pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 4.6 pricing from bundled Claude API guidance; verify exact current SKU mapping before billing-critical use.")),
310        supports_thinking: true,
311        supports_adaptive_thinking: true,
312        rejects_budget_thinking: true,
313        source_url: ANTHROPIC_MODELS_URL,
314        source_status: SourceStatus::Derived,
315        notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
316    },
317    ModelCapabilities {
318        provider: "anthropic",
319        model_id: "claude-sonnet-5",
320        context_window: Some(1_000_000),
321        max_output_tokens: Some(128_000),
322        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 5 standard pricing $3/$15 per 1M; introductory $2/$10 through 2026-08-31. A new tokenizer produces ~30% more tokens than Sonnet 4.6, so equivalent-text cost differs even at unchanged per-token rates.")),
323        supports_thinking: true,
324        supports_adaptive_thinking: true,
325        rejects_budget_thinking: true,
326        source_url: ANTHROPIC_MODELS_URL,
327        source_status: SourceStatus::Official,
328        notes: Some("Sonnet 5 is adaptive-only: adaptive thinking is on by default (applies even when `thinking` is unset) and `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API — same as Opus 4.8. Non-default sampling params (temperature/top_p/top_k) also return 400 (constraint inherited from Opus 4.7). Uses a new tokenizer (~30% more tokens than Sonnet 4.6)."),
329    },
330    ModelCapabilities {
331        provider: "anthropic",
332        model_id: "claude-sonnet-4-6",
333        context_window: Some(1_000_000),
334        max_output_tokens: Some(64_000),
335        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
336        supports_thinking: true,
337        supports_adaptive_thinking: true,
338        rejects_budget_thinking: true,
339        source_url: ANTHROPIC_MODELS_URL,
340        source_status: SourceStatus::Derived,
341        notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
342    },
343    ModelCapabilities {
344        provider: "anthropic",
345        model_id: "claude-sonnet-4-5-20250929",
346        context_window: Some(200_000),
347        max_output_tokens: Some(64_000),
348        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
349        supports_thinking: true,
350        supports_adaptive_thinking: false,
351        rejects_budget_thinking: false,
352        source_url: ANTHROPIC_MODELS_URL,
353        source_status: SourceStatus::Derived,
354        notes: None,
355    },
356    ModelCapabilities {
357        provider: "anthropic",
358        model_id: "claude-haiku-4-5-20251001",
359        context_window: Some(200_000),
360        max_output_tokens: Some(64_000),
361        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
362        supports_thinking: true,
363        supports_adaptive_thinking: false,
364        rejects_budget_thinking: false,
365        source_url: ANTHROPIC_MODELS_URL,
366        source_status: SourceStatus::Derived,
367        notes: None,
368    },
369    ModelCapabilities {
370        provider: "anthropic",
371        model_id: "claude-sonnet-4-20250514",
372        context_window: Some(200_000),
373        max_output_tokens: Some(64_000),
374        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
375        supports_thinking: true,
376        supports_adaptive_thinking: false,
377        rejects_budget_thinking: false,
378        source_url: ANTHROPIC_MODELS_URL,
379        source_status: SourceStatus::Derived,
380        notes: None,
381    },
382    ModelCapabilities {
383        provider: "anthropic",
384        model_id: "claude-opus-4-20250514",
385        context_window: Some(200_000),
386        max_output_tokens: Some(32_000),
387        pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
388        supports_thinking: true,
389        supports_adaptive_thinking: false,
390        rejects_budget_thinking: false,
391        source_url: ANTHROPIC_MODELS_URL,
392        source_status: SourceStatus::Derived,
393        notes: None,
394    },
395    ModelCapabilities {
396        provider: "anthropic",
397        model_id: "claude-3-5-sonnet-20241022",
398        context_window: Some(200_000),
399        max_output_tokens: Some(8_192),
400        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
401        supports_thinking: true,
402        supports_adaptive_thinking: false,
403        rejects_budget_thinking: false,
404        source_url: ANTHROPIC_MODELS_URL,
405        source_status: SourceStatus::Derived,
406        notes: None,
407    },
408    ModelCapabilities {
409        provider: "anthropic",
410        model_id: "claude-3-5-haiku-20241022",
411        context_window: Some(200_000),
412        max_output_tokens: Some(8_192),
413        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
414        supports_thinking: true,
415        supports_adaptive_thinking: false,
416        rejects_budget_thinking: false,
417        source_url: ANTHROPIC_MODELS_URL,
418        source_status: SourceStatus::Derived,
419        notes: None,
420    },
421    // OpenAI
422    ModelCapabilities {
423        provider: "openai",
424        model_id: "gpt-5.6",
425        context_window: Some(1_050_000),
426        max_output_tokens: Some(128_000),
427        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
428            "Standard tier base rates. Cache writes cost $6.25/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
429        )),
430        supports_thinking: true,
431        supports_adaptive_thinking: true,
432        rejects_budget_thinking: false,
433        source_url: OPENAI_GPT56_SOL_URL,
434        source_status: SourceStatus::Official,
435        notes: Some("Official alias for GPT-5.6 Sol."),
436    },
437    ModelCapabilities {
438        provider: "openai",
439        model_id: "gpt-5.6-sol",
440        context_window: Some(1_050_000),
441        max_output_tokens: Some(128_000),
442        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
443            "Standard tier base rates. Cache writes cost $6.25/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
444        )),
445        supports_thinking: true,
446        supports_adaptive_thinking: true,
447        rejects_budget_thinking: false,
448        source_url: OPENAI_GPT56_SOL_URL,
449        source_status: SourceStatus::Official,
450        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
451    },
452    ModelCapabilities {
453        provider: "openai",
454        model_id: "gpt-5.6-terra",
455        context_window: Some(1_050_000),
456        max_output_tokens: Some(128_000),
457        pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
458            "Standard tier base rates. Cache writes cost $3.125/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
459        )),
460        supports_thinking: true,
461        supports_adaptive_thinking: true,
462        rejects_budget_thinking: false,
463        source_url: OPENAI_GPT56_TERRA_URL,
464        source_status: SourceStatus::Official,
465        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
466    },
467    ModelCapabilities {
468        provider: "openai",
469        model_id: "gpt-5.6-luna",
470        context_window: Some(1_050_000),
471        max_output_tokens: Some(128_000),
472        pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
473            "Standard tier base rates. Cache writes cost $1.25/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
474        )),
475        supports_thinking: true,
476        supports_adaptive_thinking: true,
477        rejects_budget_thinking: false,
478        source_url: OPENAI_GPT56_LUNA_URL,
479        source_status: SourceStatus::Official,
480        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
481    },
482    ModelCapabilities {
483        provider: "openai",
484        model_id: "gpt-5.4",
485        context_window: Some(1_050_000),
486        max_output_tokens: Some(128_000),
487        pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
488        supports_thinking: true,
489        supports_adaptive_thinking: false,
490        rejects_budget_thinking: false,
491        source_url: OPENAI_GPT54_URL,
492        source_status: SourceStatus::Official,
493        notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
494    },
495    ModelCapabilities {
496        provider: "openai",
497        model_id: "gpt-5.3-codex",
498        context_window: Some(400_000),
499        max_output_tokens: Some(128_000),
500        pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
501        supports_thinking: true,
502        supports_adaptive_thinking: true,
503        rejects_budget_thinking: false,
504        source_url: OPENAI_GPT53_CODEX_URL,
505        source_status: SourceStatus::Official,
506        notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
507    },
508    ModelCapabilities {
509        provider: "openai",
510        model_id: "gpt-5",
511        context_window: Some(400_000),
512        max_output_tokens: Some(128_000),
513        pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
514        supports_thinking: false,
515        supports_adaptive_thinking: false,
516        rejects_budget_thinking: false,
517        source_url: OPENAI_PRICING_URL,
518        source_status: SourceStatus::Official,
519        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
520    },
521    ModelCapabilities {
522        provider: "openai",
523        model_id: "gpt-5-mini",
524        context_window: Some(400_000),
525        max_output_tokens: Some(128_000),
526        pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
527        supports_thinking: false,
528        supports_adaptive_thinking: false,
529        rejects_budget_thinking: false,
530        source_url: OPENAI_PRICING_URL,
531        source_status: SourceStatus::Official,
532        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
533    },
534    ModelCapabilities {
535        provider: "openai",
536        model_id: "gpt-5-nano",
537        context_window: Some(400_000),
538        max_output_tokens: Some(128_000),
539        pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
540        supports_thinking: false,
541        supports_adaptive_thinking: false,
542        rejects_budget_thinking: false,
543        source_url: OPENAI_PRICING_URL,
544        source_status: SourceStatus::Official,
545        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
546    },
547    ModelCapabilities {
548        provider: "openai",
549        model_id: "gpt-5.2-instant",
550        context_window: Some(400_000),
551        max_output_tokens: Some(128_000),
552        pricing: None,
553        supports_thinking: false,
554        supports_adaptive_thinking: false,
555        rejects_budget_thinking: false,
556        source_url: OPENAI_MODELS_URL,
557        source_status: SourceStatus::Unverified,
558        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
559    },
560    ModelCapabilities {
561        provider: "openai",
562        model_id: "gpt-5.2-thinking",
563        context_window: Some(400_000),
564        max_output_tokens: Some(128_000),
565        pricing: None,
566        supports_thinking: true,
567        supports_adaptive_thinking: false,
568        rejects_budget_thinking: false,
569        source_url: OPENAI_MODELS_URL,
570        source_status: SourceStatus::Unverified,
571        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
572    },
573    ModelCapabilities {
574        provider: "openai",
575        model_id: "gpt-5.2-pro",
576        context_window: Some(400_000),
577        max_output_tokens: Some(128_000),
578        pricing: Some(Pricing::flat(21.0, 168.0)),
579        supports_thinking: true,
580        supports_adaptive_thinking: false,
581        rejects_budget_thinking: false,
582        source_url: OPENAI_GPT52_PRO_URL,
583        source_status: SourceStatus::Official,
584        notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
585    },
586    ModelCapabilities {
587        provider: "openai",
588        model_id: "gpt-5.2-codex",
589        context_window: Some(400_000),
590        max_output_tokens: Some(128_000),
591        pricing: None,
592        supports_thinking: false,
593        supports_adaptive_thinking: false,
594        rejects_budget_thinking: false,
595        source_url: OPENAI_MODELS_URL,
596        source_status: SourceStatus::Unverified,
597        notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
598    },
599    ModelCapabilities {
600        provider: "openai",
601        model_id: "o3",
602        context_window: Some(200_000),
603        max_output_tokens: Some(100_000),
604        pricing: Some(Pricing::flat(1.0, 4.0)),
605        supports_thinking: true,
606        supports_adaptive_thinking: false,
607        rejects_budget_thinking: false,
608        source_url: OPENAI_PRICING_URL,
609        source_status: SourceStatus::Official,
610        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
611    },
612    ModelCapabilities {
613        provider: "openai",
614        model_id: "o3-mini",
615        context_window: Some(200_000),
616        max_output_tokens: Some(100_000),
617        pricing: Some(Pricing::flat(0.55, 2.20)),
618        supports_thinking: true,
619        supports_adaptive_thinking: false,
620        rejects_budget_thinking: false,
621        source_url: OPENAI_PRICING_URL,
622        source_status: SourceStatus::Official,
623        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
624    },
625    ModelCapabilities {
626        provider: "openai",
627        model_id: "o4-mini",
628        context_window: Some(200_000),
629        max_output_tokens: Some(100_000),
630        pricing: Some(Pricing::flat(0.55, 2.20)),
631        supports_thinking: true,
632        supports_adaptive_thinking: false,
633        rejects_budget_thinking: false,
634        source_url: OPENAI_PRICING_URL,
635        source_status: SourceStatus::Official,
636        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
637    },
638    ModelCapabilities {
639        provider: "openai",
640        model_id: "o1",
641        context_window: Some(200_000),
642        max_output_tokens: Some(100_000),
643        pricing: Some(Pricing::flat(7.50, 30.0)),
644        supports_thinking: true,
645        supports_adaptive_thinking: false,
646        rejects_budget_thinking: false,
647        source_url: OPENAI_PRICING_URL,
648        source_status: SourceStatus::Official,
649        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
650    },
651    ModelCapabilities {
652        provider: "openai",
653        model_id: "o1-mini",
654        context_window: Some(200_000),
655        max_output_tokens: Some(100_000),
656        pricing: Some(Pricing::flat(0.55, 2.20)),
657        supports_thinking: true,
658        supports_adaptive_thinking: false,
659        rejects_budget_thinking: false,
660        source_url: OPENAI_PRICING_URL,
661        source_status: SourceStatus::Official,
662        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
663    },
664    ModelCapabilities {
665        provider: "openai",
666        model_id: "gpt-4.1",
667        context_window: Some(1_000_000),
668        max_output_tokens: Some(16_384),
669        pricing: Some(Pricing::flat(1.0, 4.0)),
670        supports_thinking: false,
671        supports_adaptive_thinking: false,
672        rejects_budget_thinking: false,
673        source_url: OPENAI_PRICING_URL,
674        source_status: SourceStatus::Official,
675        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
676    },
677    ModelCapabilities {
678        provider: "openai",
679        model_id: "gpt-4.1-mini",
680        context_window: Some(1_000_000),
681        max_output_tokens: Some(16_384),
682        pricing: Some(Pricing::flat(0.20, 0.80)),
683        supports_thinking: false,
684        supports_adaptive_thinking: false,
685        rejects_budget_thinking: false,
686        source_url: OPENAI_PRICING_URL,
687        source_status: SourceStatus::Official,
688        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
689    },
690    ModelCapabilities {
691        provider: "openai",
692        model_id: "gpt-4.1-nano",
693        context_window: Some(1_000_000),
694        max_output_tokens: Some(16_384),
695        pricing: Some(Pricing::flat(0.05, 0.20)),
696        supports_thinking: false,
697        supports_adaptive_thinking: false,
698        rejects_budget_thinking: false,
699        source_url: OPENAI_PRICING_URL,
700        source_status: SourceStatus::Official,
701        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
702    },
703    ModelCapabilities {
704        provider: "openai",
705        model_id: "gpt-4o",
706        context_window: Some(128_000),
707        max_output_tokens: Some(16_384),
708        pricing: Some(Pricing::flat(1.25, 5.0)),
709        supports_thinking: false,
710        supports_adaptive_thinking: false,
711        rejects_budget_thinking: false,
712        source_url: OPENAI_PRICING_URL,
713        source_status: SourceStatus::Official,
714        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
715    },
716    ModelCapabilities {
717        provider: "openai",
718        model_id: "gpt-4o-mini",
719        context_window: Some(128_000),
720        max_output_tokens: Some(16_384),
721        pricing: Some(Pricing::flat(0.075, 0.30)),
722        supports_thinking: false,
723        supports_adaptive_thinking: false,
724        rejects_budget_thinking: false,
725        source_url: OPENAI_PRICING_URL,
726        source_status: SourceStatus::Official,
727        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
728    },
729    // Gemini
730    ModelCapabilities {
731        provider: "gemini",
732        model_id: "gemini-3.1-pro-preview",
733        context_window: Some(1_048_576),
734        max_output_tokens: Some(65_536),
735        pricing: Some(Pricing::flat(2.0, 12.0).with_notes("Official pricing for prompts <= 200K tokens. For prompts > 200K, pricing increases to $4 input / $18 output per 1M tokens.")),
736        supports_thinking: true,
737        supports_adaptive_thinking: false,
738        rejects_budget_thinking: false,
739        source_url: GOOGLE_PRICING_URL,
740        source_status: SourceStatus::Official,
741        notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
742    },
743    ModelCapabilities {
744        provider: "gemini",
745        model_id: "gemini-3.1-pro",
746        context_window: Some(1_048_576),
747        max_output_tokens: Some(65_536),
748        pricing: Some(Pricing::flat(2.0, 12.0).with_notes("Legacy alias retained for compatibility. For prompts > 200K, pricing increases to $4 input / $18 output per 1M tokens.")),
749        supports_thinking: true,
750        supports_adaptive_thinking: false,
751        rejects_budget_thinking: false,
752        source_url: GOOGLE_PRICING_URL,
753        source_status: SourceStatus::Derived,
754        notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
755    },
756    ModelCapabilities {
757        provider: "gemini",
758        model_id: "gemini-3.1-flash-lite-preview",
759        context_window: Some(1_048_576),
760        max_output_tokens: Some(65_536),
761        pricing: None,
762        supports_thinking: true,
763        supports_adaptive_thinking: false,
764        rejects_budget_thinking: false,
765        source_url: GOOGLE_MODELS_URL,
766        source_status: SourceStatus::Unverified,
767        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
768    },
769    ModelCapabilities {
770        provider: "gemini",
771        model_id: "gemini-3-flash-preview",
772        context_window: Some(1_048_576),
773        max_output_tokens: Some(65_536),
774        pricing: None,
775        supports_thinking: true,
776        supports_adaptive_thinking: false,
777        rejects_budget_thinking: false,
778        source_url: GOOGLE_MODELS_URL,
779        source_status: SourceStatus::Unverified,
780        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
781    },
782    ModelCapabilities {
783        provider: "gemini",
784        model_id: "gemini-3.0-flash",
785        context_window: Some(1_048_576),
786        max_output_tokens: Some(65_536),
787        pricing: None,
788        supports_thinking: true,
789        supports_adaptive_thinking: false,
790        rejects_budget_thinking: false,
791        source_url: GOOGLE_MODELS_URL,
792        source_status: SourceStatus::Derived,
793        notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
794    },
795    ModelCapabilities {
796        provider: "gemini",
797        model_id: "gemini-3.0-pro",
798        context_window: Some(1_048_576),
799        max_output_tokens: Some(65_536),
800        pricing: None,
801        supports_thinking: true,
802        supports_adaptive_thinking: false,
803        rejects_budget_thinking: false,
804        source_url: GOOGLE_MODELS_URL,
805        source_status: SourceStatus::Unverified,
806        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
807    },
808    ModelCapabilities {
809        provider: "gemini",
810        model_id: "gemini-2.5-flash",
811        context_window: Some(1_000_000),
812        max_output_tokens: Some(65_536),
813        pricing: Some(Pricing::flat(0.30, 2.50).with_notes("Official text/image/video pricing. Audio input is priced separately at $1.00 / 1M tokens.")),
814        supports_thinking: true,
815        supports_adaptive_thinking: false,
816        rejects_budget_thinking: false,
817        source_url: GOOGLE_PRICING_URL,
818        source_status: SourceStatus::Official,
819        notes: Some("Official docs state output pricing includes thinking tokens."),
820    },
821    ModelCapabilities {
822        provider: "gemini",
823        model_id: "gemini-2.5-pro",
824        context_window: Some(1_000_000),
825        max_output_tokens: Some(65_536),
826        pricing: None,
827        supports_thinking: true,
828        supports_adaptive_thinking: false,
829        rejects_budget_thinking: false,
830        source_url: GOOGLE_MODELS_URL,
831        source_status: SourceStatus::Unverified,
832        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
833    },
834    ModelCapabilities {
835        provider: "gemini",
836        model_id: "gemini-2.0-flash",
837        context_window: Some(1_000_000),
838        max_output_tokens: Some(8_192),
839        pricing: Some(Pricing::flat(0.10, 0.40).with_notes("Official text/image/video pricing. Audio input is priced separately at $0.70 / 1M tokens.")),
840        supports_thinking: false,
841        supports_adaptive_thinking: false,
842        rejects_budget_thinking: false,
843        source_url: GOOGLE_PRICING_URL,
844        source_status: SourceStatus::Official,
845        notes: None,
846    },
847    ModelCapabilities {
848        provider: "gemini",
849        model_id: "gemini-2.0-flash-lite",
850        context_window: Some(1_000_000),
851        max_output_tokens: Some(8_192),
852        pricing: Some(Pricing::flat(0.075, 0.30)),
853        supports_thinking: false,
854        supports_adaptive_thinking: false,
855        rejects_budget_thinking: false,
856        source_url: GOOGLE_PRICING_URL,
857        source_status: SourceStatus::Official,
858        notes: None,
859    },
860    // Open models (z.ai / Moonshot / DeepSeek / MiniMax). All routed through
861    // OpenAIProvider, so provider == "openai" and the model_id is the exact
862    // string the caller passes (OpenRouter slug or native model id).
863    ModelCapabilities {
864        provider: "openai",
865        model_id: "z-ai/glm-5.1",
866        context_window: Some(202_752),
867        max_output_tokens: Some(131_072),
868        pricing: Some(Pricing::flat(0.98, 3.08).with_notes("OpenRouter rate for z-ai/glm-5.1: input $0.98/M, output $3.08/M.")),
869        supports_thinking: true,
870        supports_adaptive_thinking: false,
871        rejects_budget_thinking: false,
872        source_url: OPENROUTER_GLM51_URL,
873        source_status: SourceStatus::Derived,
874        notes: Some("GLM-5.1 (z.ai/Zhipu) via OpenRouter slug. Reasoning/thinking model; context 203K (=202,752). max_output 128K from z.ai GLM-5.1 docs, sized generously for hidden reasoning + answer. Released ~Apr 7, 2026."),
875    },
876    ModelCapabilities {
877        provider: "openai",
878        model_id: "glm-5",
879        context_window: Some(200_000),
880        max_output_tokens: Some(131_072),
881        pricing: Some(Pricing::flat(1.0, 3.2).with_notes("Native z.ai pricing: input $1.0/M, output $3.2/M (higher than the OpenRouter GLM-5 rate of $0.60/$1.92).")),
882        supports_thinking: true,
883        supports_adaptive_thinking: false,
884        rejects_budget_thinking: false,
885        source_url: ZAI_GLM5_PRICING_URL,
886        source_status: SourceStatus::Derived,
887        notes: Some("Native z.ai constructor model string `glm-5`. Reasoning/thinking model; 200K context, 128K (131072) max output per docs.z.ai/guides/llm/glm-5. Native pricing used for the native route. Released ~Feb 11, 2026."),
888    },
889    ModelCapabilities {
890        provider: "openai",
891        model_id: "moonshotai/kimi-k2.6",
892        context_window: Some(262_144),
893        max_output_tokens: Some(65_536),
894        pricing: Some(Pricing::flat(0.684, 3.42).with_notes("OpenRouter rate for moonshotai/kimi-k2.6: input $0.684/M, output $3.42/M.")),
895        supports_thinking: false,
896        supports_adaptive_thinking: false,
897        rejects_budget_thinking: false,
898        source_url: OPENROUTER_KIMI_K26_URL,
899        source_status: SourceStatus::Derived,
900        notes: Some("Exact OpenRouter slug (note the dot). Hybrid model marketed/used as a non-reasoning coding+multimodal model, so supports_thinking=false (use moonshotai/kimi-k2-thinking for the dedicated reasoning model). Context 262,144; 65536 is a generous app-side completion budget within the window."),
901    },
902    ModelCapabilities {
903        provider: "openai",
904        model_id: "moonshotai/kimi-k2.5",
905        context_window: Some(262_144),
906        max_output_tokens: Some(32_768),
907        pricing: Some(Pricing::flat(0.4, 1.9).with_notes("OpenRouter rate for moonshotai/kimi-k2.5: input $0.40/M, output $1.90/M.")),
908        supports_thinking: false,
909        supports_adaptive_thinking: false,
910        rejects_budget_thinking: false,
911        source_url: OPENROUTER_KIMI_K25_URL,
912        source_status: SourceStatus::Derived,
913        notes: Some("OpenRouter route for the model the native constructor names 'kimi-k2.5'. Treated as non-reasoning (visual-coding + agentic tool-calling) on OpenRouter. Context 262,144; 32768 is a generous app-side completion budget within the window."),
914    },
915    ModelCapabilities {
916        provider: "openai",
917        model_id: "kimi-k2.5",
918        context_window: Some(262_144),
919        max_output_tokens: Some(32_768),
920        pricing: Some(Pricing::flat(0.6, 3.0).with_notes("Native Moonshot estimate from Artificial Analysis (~$0.58 in / $3.00 out); input rounded up to $0.60 to stay conservative for budget reservation.")),
921        supports_thinking: false,
922        supports_adaptive_thinking: false,
923        rejects_budget_thinking: false,
924        source_url: KIMI_K25_AA_URL,
925        source_status: SourceStatus::Unverified,
926        notes: Some("Exact native model_id used by the native constructor (Moonshot platform.kimi.ai base_url). Native pricing not on the first-party table (only k2.6 is enumerated); figures derived from Artificial Analysis. Context 262,144; 32768 is a generous within-window completion budget."),
927    },
928    ModelCapabilities {
929        provider: "openai",
930        model_id: "kimi-k2-thinking",
931        context_window: Some(262_144),
932        max_output_tokens: Some(131_072),
933        pricing: Some(Pricing::flat(0.6, 2.5).with_notes("Cross-provider median for kimi-k2-thinking (OpenRouter/Artificial Analysis): input $0.60/M, output $2.50/M, used as a conservative native estimate.")),
934        supports_thinking: true,
935        supports_adaptive_thinking: false,
936        rejects_budget_thinking: false,
937        source_url: OPENROUTER_KIMI_K2_THINKING_URL,
938        source_status: SourceStatus::Unverified,
939        notes: Some("Exact native model_id used by the native constructor; a REASONING model (emits hidden chain-of-thought before the answer). Native Moonshot base_url. First-party pricing could not be isolated; figures are the cross-provider median. Context 262,144; max_output 131072 sized generously for reasoning tokens, within the window."),
940    },
941    ModelCapabilities {
942        provider: "openai",
943        model_id: "deepseek/deepseek-v4-pro",
944        context_window: Some(1_048_576),
945        max_output_tokens: Some(384_000),
946        pricing: Some(Pricing::flat(0.44, 0.87).with_notes("OpenRouter effective post-promo rate ($0.435 in rounded up to $0.44 / $0.87 out). Pre-promo regular rate was $1.74/$3.48.")),
947        supports_thinking: true,
948        supports_adaptive_thinking: false,
949        rejects_budget_thinking: false,
950        source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
951        source_status: SourceStatus::Derived,
952        notes: Some("Primary model named in forge config; exact OpenRouter slug. Large MoE (1.6T total / 49B active), released 2026-04-24. Reasoning/thinking model; DeepSeek returns the answer in `content` and chain-of-thought in a separate `reasoning_content` field, which must be echoed back in subsequent thinking-mode turns or the API returns 400. Max output 384K (DeepSeek ceiling), sized generously for reasoning."),
953    },
954    ModelCapabilities {
955        provider: "openai",
956        model_id: "deepseek-v4-pro",
957        context_window: Some(1_048_576),
958        max_output_tokens: Some(384_000),
959        pricing: Some(Pricing::flat_with_cached(0.44, 0.87, 0.003_625).with_notes("Official DeepSeek pricing: input cache-MISS $0.435/M (rounded up to $0.44), cache-HIT $0.003625/M, output $0.87/M.")),
960        supports_thinking: true,
961        supports_adaptive_thinking: false,
962        rejects_budget_thinking: false,
963        source_url: DEEPSEEK_PRICING_URL,
964        source_status: SourceStatus::Derived,
965        notes: Some("Native DeepSeek API model id 'deepseek-v4-pro' (no vendor prefix). 1M context, 384K max output. Reasoning/thinking model; separate `reasoning_content` that must be echoed back in multi-turn thinking-mode requests or you get a 400. Legacy ids deepseek-reasoner/deepseek-chat now map to V4-FLASH, not Pro."),
966    },
967    ModelCapabilities {
968        provider: "openai",
969        model_id: "deepseek/deepseek-v4-flash",
970        context_window: Some(1_048_576),
971        max_output_tokens: Some(384_000),
972        pricing: Some(Pricing::flat(0.15, 0.28).with_notes("DeepSeek list rate rounded up ($0.14 in -> $0.15 / $0.28 out) used instead of OpenRouter's lower fluctuating effective rate so consumers never under-reserve budget.")),
973        supports_thinking: true,
974        supports_adaptive_thinking: false,
975        rejects_budget_thinking: false,
976        source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
977        source_status: SourceStatus::Derived,
978        notes: Some("Sibling V4 model (cheaper routing target). Efficiency MoE (284B total / 13B active), released 2026-04-24. Reasoning/thinking model with the same reasoning_content split + mandatory pass-back-or-400 behavior as V4 Pro. Max output 384K per DeepSeek docs."),
979    },
980    ModelCapabilities {
981        provider: "openai",
982        model_id: "deepseek-v4-flash",
983        context_window: Some(1_048_576),
984        max_output_tokens: Some(384_000),
985        pricing: Some(Pricing::flat_with_cached(0.14, 0.28, 0.002_8).with_notes("Official DeepSeek pricing: input cache-MISS $0.14/M, cache-HIT $0.0028/M, output $0.28/M.")),
986        supports_thinking: true,
987        supports_adaptive_thinking: false,
988        rejects_budget_thinking: false,
989        source_url: DEEPSEEK_PRICING_URL,
990        source_status: SourceStatus::Derived,
991        notes: Some("Native DeepSeek API model id 'deepseek-v4-flash'. 1M context, 384K max output. Reasoning/thinking model; same content/reasoning_content split and mandatory pass-back in thinking mode. Legacy aliases deepseek-chat/deepseek-reasoner now resolve to this Flash model."),
992    },
993    ModelCapabilities {
994        provider: "openai",
995        model_id: "MiniMax-M2.5",
996        context_window: Some(204_800),
997        max_output_tokens: Some(131_072),
998        pricing: Some(Pricing::flat_with_cached(0.3, 1.2, 0.03).with_notes("Native MiniMax first-party pricing: input $0.30/M, output $1.20/M, cache-read input $0.03/M (platform.minimax.io PAYG).")),
999        supports_thinking: true,
1000        supports_adaptive_thinking: false,
1001        rejects_budget_thinking: false,
1002        source_url: MINIMAX_PRICING_URL,
1003        source_status: SourceStatus::Derived,
1004        notes: Some("Native agent-sdk constructor model string 'MiniMax-M2.5' (api.minimax.io, OpenAI-compatible). Reasoning/thinking model; emits chain-of-thought in <think>...</think> tags and supports interleaved thinking. Context 204,800; max_output 131072 sized generously for hidden reasoning + answer within the window."),
1005    },
1006    ModelCapabilities {
1007        provider: "openai",
1008        model_id: "minimax/minimax-m2.5",
1009        context_window: Some(204_800),
1010        max_output_tokens: Some(131_072),
1011        pricing: Some(Pricing::flat(0.15, 1.15).with_notes("OpenRouter rate for minimax/minimax-m2.5: input $0.15/M, output $1.15/M (lower than MiniMax's $0.30/$1.20 first-party rate; OpenRouter prices can fluctuate, so reserve conservatively).")),
1012        supports_thinking: true,
1013        supports_adaptive_thinking: false,
1014        rejects_budget_thinking: false,
1015        source_url: OPENROUTER_MINIMAX_M25_URL,
1016        source_status: SourceStatus::Derived,
1017        notes: Some("OpenRouter slug 'minimax/minimax-m2.5' (same M2.5 weights as native). Reasoning/thinking model. Context 204,800; max_output 131072 sized generously for hidden reasoning tokens before the answer."),
1018    },
1019];
1020
1021#[must_use]
1022pub fn get_model_capabilities(
1023    provider: &str,
1024    model_id: &str,
1025) -> Option<&'static ModelCapabilities> {
1026    MODEL_CAPABILITIES.iter().find(|caps| {
1027        caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
1028    })
1029}
1030
1031#[must_use]
1032pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
1033    get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
1034}
1035
1036#[must_use]
1037pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
1038    MODEL_CAPABILITIES
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    /// A row where reasoning is DEARER than output — `alibaba/qwen3-32b` in the
1046    /// live feed ($2.80/M output, $8.40/M reasoning). Reasoning tokens ride
1047    /// inside `output_tokens`, so the whole output band must bill at the higher
1048    /// reasoning rate, or the estimate under-bills and the cap can miss.
1049    #[test]
1050    fn output_bills_at_the_dearer_reasoning_rate() -> anyhow::Result<()> {
1051        use anyhow::Context;
1052        let pricing = Pricing::flat(0.7, 2.8).with_reasoning(8.4);
1053        let usage = Usage {
1054            served_speed: None,
1055            input_tokens: 1_000_000,
1056            output_tokens: 1_000_000,
1057            cached_input_tokens: 0,
1058            cache_creation_input_tokens: 0,
1059        };
1060        // 1M input @ $0.70 + 1M output @ max($2.80, $8.40) = 0.7 + 8.4 = 9.1.
1061        let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1062        assert!((cost - 9.1).abs() < 1e-9, "unexpected cost: {cost}");
1063        Ok(())
1064    }
1065
1066    /// When reasoning is CHEAPER than output the max leaves output unchanged —
1067    /// billing the whole band at the output rate (a bounded over-estimate for
1068    /// the reasoning tokens, the safe direction).
1069    #[test]
1070    fn output_keeps_the_dearer_output_rate() -> anyhow::Result<()> {
1071        use anyhow::Context;
1072        let pricing = Pricing::flat(1.0, 8.0).with_reasoning(3.0);
1073        let usage = Usage {
1074            served_speed: None,
1075            input_tokens: 0,
1076            output_tokens: 1_000_000,
1077            cached_input_tokens: 0,
1078            cache_creation_input_tokens: 0,
1079        };
1080        // 1M output @ max($8, $3) = $8.
1081        let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1082        assert!((cost - 8.0).abs() < 1e-9, "unexpected cost: {cost}");
1083        Ok(())
1084    }
1085
1086    #[test]
1087    fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
1088        use anyhow::Context;
1089
1090        let caps = get_model_capabilities("anthropic", "claude-fable-5")
1091            .context("claude-fable-5 capabilities missing")?;
1092        assert_eq!(caps.context_window, Some(1_000_000));
1093        assert_eq!(caps.max_output_tokens, Some(128_000));
1094        assert!(caps.supports_thinking);
1095        assert!(caps.supports_adaptive_thinking);
1096        assert_eq!(caps.source_status, SourceStatus::Official);
1097        let pricing = caps.pricing.context("pricing missing")?;
1098        let input = pricing.input.context("input price missing")?;
1099        let output = pricing.output.context("output price missing")?;
1100        assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
1101        assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
1102        Ok(())
1103    }
1104
1105    #[test]
1106    fn test_lookup_anthropic_opus_5() -> anyhow::Result<()> {
1107        use anyhow::Context;
1108
1109        let caps = get_model_capabilities("anthropic", "claude-opus-5")
1110            .context("claude-opus-5 capabilities missing")?;
1111        assert_eq!(caps.context_window, Some(1_000_000));
1112        assert_eq!(caps.max_output_tokens, Some(128_000));
1113        assert!(caps.supports_thinking);
1114        // Like Opus 4.8: manual budget_tokens 400s; adaptive supported, not required.
1115        assert!(caps.supports_adaptive_thinking);
1116        assert_eq!(caps.source_status, SourceStatus::Official);
1117        let pricing = caps.pricing.context("pricing missing")?;
1118        let input = pricing.input.context("input price missing")?;
1119        let output = pricing.output.context("output price missing")?;
1120        assert!((input.usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1121        assert!((output.usd_per_million_tokens - 25.0).abs() < f64::EPSILON);
1122        Ok(())
1123    }
1124
1125    #[test]
1126    fn test_lookup_anthropic_opus_48() {
1127        let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
1128        assert_eq!(caps.context_window, Some(1_000_000));
1129        assert_eq!(caps.max_output_tokens, Some(128_000));
1130        assert!(caps.supports_thinking);
1131        assert!(caps.supports_adaptive_thinking);
1132    }
1133
1134    #[test]
1135    fn test_lookup_anthropic_opus_46() {
1136        let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
1137        assert_eq!(caps.context_window, Some(1_000_000));
1138        assert_eq!(caps.max_output_tokens, Some(128_000));
1139        assert!(caps.supports_adaptive_thinking);
1140    }
1141
1142    #[test]
1143    fn test_lookup_anthropic_sonnet_5() {
1144        let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
1145        assert_eq!(caps.context_window, Some(1_000_000));
1146        assert_eq!(caps.max_output_tokens, Some(128_000));
1147        assert!(caps.supports_thinking);
1148        // Like Opus 4.8: manual budget_tokens 400s; adaptive supported, not required.
1149        assert!(caps.supports_adaptive_thinking);
1150    }
1151
1152    #[test]
1153    fn test_lookup_anthropic_sonnet_46() {
1154        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
1155        assert_eq!(caps.context_window, Some(1_000_000));
1156        assert_eq!(caps.max_output_tokens, Some(64_000));
1157        assert!(caps.supports_adaptive_thinking);
1158    }
1159
1160    #[test]
1161    fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
1162        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
1163        assert!(!caps.supports_adaptive_thinking);
1164    }
1165
1166    #[test]
1167    fn budget_rejecting_anthropic_models_carry_the_flag() {
1168        for model in [
1169            "claude-fable-5",
1170            "claude-opus-5",
1171            "claude-opus-4-8",
1172            "claude-opus-4-7",
1173            "claude-opus-4-6",
1174            "claude-sonnet-5",
1175            "claude-sonnet-4-6",
1176        ] {
1177            let caps = get_model_capabilities("anthropic", model).unwrap();
1178            assert!(
1179                caps.rejects_budget_thinking,
1180                "{model} rejects budget thinking and must carry the flag"
1181            );
1182        }
1183    }
1184
1185    #[test]
1186    fn rejects_budget_thinking_implies_adaptive_is_available() {
1187        for caps in MODEL_CAPABILITIES {
1188            if caps.rejects_budget_thinking {
1189                assert!(
1190                    caps.supports_adaptive_thinking,
1191                    "{}/{} rejects budget thinking but does not support adaptive — \
1192                     callers would have no thinking-object shape to promote to",
1193                    caps.provider, caps.model_id,
1194                );
1195            }
1196        }
1197    }
1198
1199    #[test]
1200    fn budget_capable_models_do_not_carry_the_flag() {
1201        for (provider, model) in [
1202            ("anthropic", "claude-sonnet-4-5-20250929"),
1203            ("anthropic", "claude-haiku-4-5-20251001"),
1204            ("openai", "gpt-5.6-sol"),
1205        ] {
1206            let caps = get_model_capabilities(provider, model).unwrap();
1207            assert!(
1208                !caps.rejects_budget_thinking,
1209                "{provider}/{model} accepts budget-style thinking config"
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn test_lookup_openai_pricing() {
1216        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1217        let pricing = caps.pricing.unwrap();
1218        assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
1219        assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1220    }
1221
1222    #[test]
1223    fn test_lookup_openai_gpt54() {
1224        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1225        assert_eq!(caps.context_window, Some(1_050_000));
1226        assert_eq!(caps.max_output_tokens, Some(128_000));
1227        assert!(caps.supports_thinking);
1228        assert_eq!(caps.source_status, SourceStatus::Official);
1229    }
1230
1231    #[test]
1232    fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
1233        use anyhow::Context;
1234
1235        let caps = get_model_capabilities("openai", "gpt-5.2-pro")
1236            .context("gpt-5.2-pro capabilities missing")?;
1237        assert_eq!(caps.context_window, Some(400_000));
1238        assert_eq!(caps.max_output_tokens, Some(128_000));
1239        assert!(caps.supports_thinking);
1240        assert_eq!(caps.source_status, SourceStatus::Official);
1241        let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
1242        let input = pricing.input.context("input price missing")?;
1243        let output = pricing.output.context("output price missing")?;
1244        assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
1245        assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
1246        Ok(())
1247    }
1248
1249    #[test]
1250    fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
1251        use anyhow::Context as _;
1252
1253        for (model_id, input, cached_input, output, cache_write_note) in [
1254            ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
1255            ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
1256            ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
1257            ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
1258        ] {
1259            let caps = get_model_capabilities("openai", model_id)
1260                .with_context(|| format!("{model_id} capabilities missing"))?;
1261            assert_eq!(caps.context_window, Some(1_050_000));
1262            assert_eq!(caps.max_output_tokens, Some(128_000));
1263            assert!(caps.supports_thinking);
1264            assert!(caps.supports_adaptive_thinking);
1265            assert_eq!(caps.source_status, SourceStatus::Official);
1266            let pricing = caps
1267                .pricing
1268                .with_context(|| format!("{model_id} pricing missing"))?;
1269            assert_eq!(pricing.input, Some(PricePoint::new(input)));
1270            assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
1271            assert_eq!(pricing.output, Some(PricePoint::new(output)));
1272            assert!(pricing.notes.is_some_and(|notes| {
1273                notes.contains(cache_write_note) && notes.contains("more than 272K")
1274            }));
1275        }
1276
1277        Ok(())
1278    }
1279
1280    #[test]
1281    fn test_lookup_openai_gpt53_codex() {
1282        let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
1283        assert_eq!(caps.context_window, Some(400_000));
1284        assert_eq!(caps.max_output_tokens, Some(128_000));
1285        assert!(caps.supports_adaptive_thinking);
1286        assert!(caps.supports_thinking);
1287        assert_eq!(caps.source_status, SourceStatus::Official);
1288    }
1289
1290    #[test]
1291    fn test_lookup_gemini_preview_models() {
1292        let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1293        assert_eq!(flash.context_window, Some(1_048_576));
1294        assert!(flash.supports_thinking);
1295
1296        let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1297        assert_eq!(pro.max_output_tokens, Some(65_536));
1298        assert!(pro.supports_thinking);
1299    }
1300
1301    #[test]
1302    fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1303        // DeepSeek V4 Pro via OpenRouter slug — reasoning model.
1304        let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1305        assert!(deepseek.supports_thinking);
1306        assert_eq!(deepseek.max_output_tokens, Some(384_000));
1307        let pricing = deepseek.pricing.unwrap();
1308        assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1309        assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1310
1311        // z.ai GLM-5.1 via OpenRouter slug — reasoning model.
1312        let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1313        assert!(glm.supports_thinking);
1314        assert_eq!(glm.max_output_tokens, Some(131_072));
1315        let glm_pricing = glm.pricing.unwrap();
1316        assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1317        assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1318
1319        // Kimi K2 Thinking native — reasoning model.
1320        let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1321        assert!(kimi_thinking.supports_thinking);
1322        assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1323        assert!(
1324            kimi_thinking
1325                .pricing
1326                .unwrap()
1327                .output
1328                .unwrap()
1329                .usd_per_million_tokens
1330                > 0.0
1331        );
1332    }
1333
1334    #[test]
1335    fn test_lookup_open_non_reasoning_kimi_models() {
1336        // Kimi K2.6 / K2.5 are registered as non-reasoning coding models.
1337        let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1338        assert!(!k26.supports_thinking);
1339        assert_eq!(k26.max_output_tokens, Some(65_536));
1340        assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1341
1342        let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1343        assert!(!k25_native.supports_thinking);
1344        assert_eq!(k25_native.max_output_tokens, Some(32_768));
1345    }
1346
1347    #[test]
1348    fn test_lookup_all_open_models_resolve() {
1349        // Every model_id below is exactly how the consumer looks them up
1350        // (provider == "openai" for all open routes).
1351        for model_id in [
1352            "z-ai/glm-5.1",
1353            "glm-5",
1354            "moonshotai/kimi-k2.6",
1355            "moonshotai/kimi-k2.5",
1356            "kimi-k2.5",
1357            "kimi-k2-thinking",
1358            "deepseek/deepseek-v4-pro",
1359            "deepseek-v4-pro",
1360            "deepseek/deepseek-v4-flash",
1361            "deepseek-v4-flash",
1362            "MiniMax-M2.5",
1363            "minimax/minimax-m2.5",
1364        ] {
1365            let caps = get_model_capabilities("openai", model_id)
1366                .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1367            assert!(
1368                caps.pricing.is_some(),
1369                "pricing should be populated for {model_id}"
1370            );
1371            assert!(
1372                caps.max_output_tokens.is_some_and(|m| m > 0),
1373                "max_output_tokens should be non-zero for {model_id}"
1374            );
1375            assert!(
1376                caps.context_window.is_some_and(|c| c > 0),
1377                "context_window should be non-zero for {model_id}"
1378            );
1379        }
1380    }
1381
1382    #[test]
1383    fn test_lookup_minimax_native_pricing() {
1384        let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1385        assert!(native.supports_thinking);
1386        let pricing = native.pricing.unwrap();
1387        assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1388        assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1389        // Cache-read is the first-party platform.minimax.io PAYG rate ($0.03/M),
1390        // not the ~$0.155/M that an earlier entry overstated by ~3-5x.
1391        assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1392    }
1393
1394    #[test]
1395    fn test_estimate_cost_usd() {
1396        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1397        let cost = caps
1398            .estimate_cost_usd(&Usage {
1399                served_speed: None,
1400                input_tokens: 2_000,
1401                output_tokens: 1_000,
1402                cached_input_tokens: 0,
1403                cache_creation_input_tokens: 0,
1404            })
1405            .unwrap();
1406        assert!((cost - 0.0075).abs() < f64::EPSILON);
1407    }
1408
1409    #[test]
1410    fn test_estimate_cost_usd_with_cached_input() {
1411        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1412        let cost = caps
1413            .estimate_cost_usd(&Usage {
1414                served_speed: None,
1415                input_tokens: 2_000,
1416                output_tokens: 1_000,
1417                cached_input_tokens: 1_000,
1418                cache_creation_input_tokens: 0,
1419            })
1420            .unwrap();
1421        assert!((cost - 0.01775).abs() < f64::EPSILON);
1422    }
1423}