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    pub source_url: &'static str,
202    pub source_status: SourceStatus,
203    pub notes: Option<&'static str>,
204}
205
206impl ModelCapabilities {
207    #[must_use]
208    pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
209        self.pricing
210            .as_ref()
211            .and_then(|p| p.estimate_cost_usd(usage))
212    }
213}
214
215const ANTHROPIC_MODELS_URL: &str =
216    "https://docs.anthropic.com/en/docs/about-claude/models/all-models";
217const OPENAI_MODELS_URL: &str = "https://developers.openai.com/api/docs/models";
218const OPENAI_PRICING_URL: &str = "https://developers.openai.com/api/docs/pricing";
219const OPENAI_GPT56_SOL_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-sol";
220const OPENAI_GPT56_TERRA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-terra";
221const OPENAI_GPT56_LUNA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-luna";
222const OPENAI_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
223const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
224const OPENAI_GPT52_PRO_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.2-pro";
225const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
226const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
227
228// Open-model routes. All reached through OpenAIProvider (provider()=="openai"),
229// whether via OpenRouter slugs or the native z.ai / Moonshot / MiniMax base URLs.
230const OPENROUTER_GLM51_URL: &str = "https://openrouter.ai/z-ai/glm-5.1";
231const ZAI_GLM5_PRICING_URL: &str = "https://docs.z.ai/guides/overview/pricing";
232const OPENROUTER_KIMI_K26_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.6";
233const OPENROUTER_KIMI_K25_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.5";
234const KIMI_K25_AA_URL: &str = "https://artificialanalysis.ai/models/kimi-k2-5";
235const OPENROUTER_KIMI_K2_THINKING_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2-thinking";
236const OPENROUTER_DEEPSEEK_V4_PRO_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-pro";
237const OPENROUTER_DEEPSEEK_V4_FLASH_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-flash";
238const DEEPSEEK_PRICING_URL: &str = "https://api-docs.deepseek.com/quick_start/pricing";
239const MINIMAX_PRICING_URL: &str = "https://platform.minimax.io/docs/guides/pricing-paygo";
240const OPENROUTER_MINIMAX_M25_URL: &str = "https://openrouter.ai/minimax/minimax-m2.5";
241
242const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
243    // Anthropic
244    ModelCapabilities {
245        provider: "anthropic",
246        model_id: "claude-fable-5",
247        context_window: Some(1_000_000),
248        max_output_tokens: Some(128_000),
249        pricing: Some(Pricing::flat(10.0, 50.0).with_notes("Anthropic Fable 5 official pricing: $10 input / $50 output per 1M tokens.")),
250        supports_thinking: true,
251        supports_adaptive_thinking: true,
252        source_url: ANTHROPIC_MODELS_URL,
253        source_status: SourceStatus::Official,
254        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."),
255    },
256    ModelCapabilities {
257        provider: "anthropic",
258        model_id: "claude-opus-5",
259        context_window: Some(1_000_000),
260        max_output_tokens: Some(128_000),
261        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.")),
262        supports_thinking: true,
263        supports_adaptive_thinking: true,
264        source_url: ANTHROPIC_MODELS_URL,
265        source_status: SourceStatus::Official,
266        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`."),
267    },
268    ModelCapabilities {
269        provider: "anthropic",
270        model_id: "claude-opus-4-8",
271        context_window: Some(1_000_000),
272        max_output_tokens: Some(128_000),
273        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.")),
274        supports_thinking: true,
275        supports_adaptive_thinking: true,
276        source_url: ANTHROPIC_MODELS_URL,
277        source_status: SourceStatus::Derived,
278        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`."),
279    },
280    ModelCapabilities {
281        provider: "anthropic",
282        model_id: "claude-opus-4-7",
283        context_window: Some(1_000_000),
284        max_output_tokens: Some(128_000),
285        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.")),
286        supports_thinking: true,
287        supports_adaptive_thinking: true,
288        source_url: ANTHROPIC_MODELS_URL,
289        source_status: SourceStatus::Derived,
290        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`."),
291    },
292    ModelCapabilities {
293        provider: "anthropic",
294        model_id: "claude-opus-4-6",
295        context_window: Some(1_000_000),
296        max_output_tokens: Some(128_000),
297        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.")),
298        supports_thinking: true,
299        supports_adaptive_thinking: true,
300        source_url: ANTHROPIC_MODELS_URL,
301        source_status: SourceStatus::Derived,
302        notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
303    },
304    ModelCapabilities {
305        provider: "anthropic",
306        model_id: "claude-sonnet-5",
307        context_window: Some(1_000_000),
308        max_output_tokens: Some(128_000),
309        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.")),
310        supports_thinking: true,
311        supports_adaptive_thinking: true,
312        source_url: ANTHROPIC_MODELS_URL,
313        source_status: SourceStatus::Official,
314        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)."),
315    },
316    ModelCapabilities {
317        provider: "anthropic",
318        model_id: "claude-sonnet-4-6",
319        context_window: Some(1_000_000),
320        max_output_tokens: Some(64_000),
321        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
322        supports_thinking: true,
323        supports_adaptive_thinking: true,
324        source_url: ANTHROPIC_MODELS_URL,
325        source_status: SourceStatus::Derived,
326        notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
327    },
328    ModelCapabilities {
329        provider: "anthropic",
330        model_id: "claude-sonnet-4-5-20250929",
331        context_window: Some(200_000),
332        max_output_tokens: Some(64_000),
333        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
334        supports_thinking: true,
335        supports_adaptive_thinking: false,
336        source_url: ANTHROPIC_MODELS_URL,
337        source_status: SourceStatus::Derived,
338        notes: None,
339    },
340    ModelCapabilities {
341        provider: "anthropic",
342        model_id: "claude-haiku-4-5-20251001",
343        context_window: Some(200_000),
344        max_output_tokens: Some(64_000),
345        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
346        supports_thinking: true,
347        supports_adaptive_thinking: false,
348        source_url: ANTHROPIC_MODELS_URL,
349        source_status: SourceStatus::Derived,
350        notes: None,
351    },
352    ModelCapabilities {
353        provider: "anthropic",
354        model_id: "claude-sonnet-4-20250514",
355        context_window: Some(200_000),
356        max_output_tokens: Some(64_000),
357        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
358        supports_thinking: true,
359        supports_adaptive_thinking: false,
360        source_url: ANTHROPIC_MODELS_URL,
361        source_status: SourceStatus::Derived,
362        notes: None,
363    },
364    ModelCapabilities {
365        provider: "anthropic",
366        model_id: "claude-opus-4-20250514",
367        context_window: Some(200_000),
368        max_output_tokens: Some(32_000),
369        pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
370        supports_thinking: true,
371        supports_adaptive_thinking: false,
372        source_url: ANTHROPIC_MODELS_URL,
373        source_status: SourceStatus::Derived,
374        notes: None,
375    },
376    ModelCapabilities {
377        provider: "anthropic",
378        model_id: "claude-3-5-sonnet-20241022",
379        context_window: Some(200_000),
380        max_output_tokens: Some(8_192),
381        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
382        supports_thinking: true,
383        supports_adaptive_thinking: false,
384        source_url: ANTHROPIC_MODELS_URL,
385        source_status: SourceStatus::Derived,
386        notes: None,
387    },
388    ModelCapabilities {
389        provider: "anthropic",
390        model_id: "claude-3-5-haiku-20241022",
391        context_window: Some(200_000),
392        max_output_tokens: Some(8_192),
393        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
394        supports_thinking: true,
395        supports_adaptive_thinking: false,
396        source_url: ANTHROPIC_MODELS_URL,
397        source_status: SourceStatus::Derived,
398        notes: None,
399    },
400    // OpenAI
401    ModelCapabilities {
402        provider: "openai",
403        model_id: "gpt-5.6",
404        context_window: Some(1_050_000),
405        max_output_tokens: Some(128_000),
406        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
407            "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.",
408        )),
409        supports_thinking: true,
410        supports_adaptive_thinking: true,
411        source_url: OPENAI_GPT56_SOL_URL,
412        source_status: SourceStatus::Official,
413        notes: Some("Official alias for GPT-5.6 Sol."),
414    },
415    ModelCapabilities {
416        provider: "openai",
417        model_id: "gpt-5.6-sol",
418        context_window: Some(1_050_000),
419        max_output_tokens: Some(128_000),
420        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
421            "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.",
422        )),
423        supports_thinking: true,
424        supports_adaptive_thinking: true,
425        source_url: OPENAI_GPT56_SOL_URL,
426        source_status: SourceStatus::Official,
427        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
428    },
429    ModelCapabilities {
430        provider: "openai",
431        model_id: "gpt-5.6-terra",
432        context_window: Some(1_050_000),
433        max_output_tokens: Some(128_000),
434        pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
435            "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.",
436        )),
437        supports_thinking: true,
438        supports_adaptive_thinking: true,
439        source_url: OPENAI_GPT56_TERRA_URL,
440        source_status: SourceStatus::Official,
441        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
442    },
443    ModelCapabilities {
444        provider: "openai",
445        model_id: "gpt-5.6-luna",
446        context_window: Some(1_050_000),
447        max_output_tokens: Some(128_000),
448        pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
449            "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.",
450        )),
451        supports_thinking: true,
452        supports_adaptive_thinking: true,
453        source_url: OPENAI_GPT56_LUNA_URL,
454        source_status: SourceStatus::Official,
455        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
456    },
457    ModelCapabilities {
458        provider: "openai",
459        model_id: "gpt-5.4",
460        context_window: Some(1_050_000),
461        max_output_tokens: Some(128_000),
462        pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
463        supports_thinking: true,
464        supports_adaptive_thinking: false,
465        source_url: OPENAI_GPT54_URL,
466        source_status: SourceStatus::Official,
467        notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
468    },
469    ModelCapabilities {
470        provider: "openai",
471        model_id: "gpt-5.3-codex",
472        context_window: Some(400_000),
473        max_output_tokens: Some(128_000),
474        pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
475        supports_thinking: true,
476        supports_adaptive_thinking: true,
477        source_url: OPENAI_GPT53_CODEX_URL,
478        source_status: SourceStatus::Official,
479        notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
480    },
481    ModelCapabilities {
482        provider: "openai",
483        model_id: "gpt-5",
484        context_window: Some(400_000),
485        max_output_tokens: Some(128_000),
486        pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
487        supports_thinking: false,
488        supports_adaptive_thinking: false,
489        source_url: OPENAI_PRICING_URL,
490        source_status: SourceStatus::Official,
491        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
492    },
493    ModelCapabilities {
494        provider: "openai",
495        model_id: "gpt-5-mini",
496        context_window: Some(400_000),
497        max_output_tokens: Some(128_000),
498        pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
499        supports_thinking: false,
500        supports_adaptive_thinking: false,
501        source_url: OPENAI_PRICING_URL,
502        source_status: SourceStatus::Official,
503        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
504    },
505    ModelCapabilities {
506        provider: "openai",
507        model_id: "gpt-5-nano",
508        context_window: Some(400_000),
509        max_output_tokens: Some(128_000),
510        pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
511        supports_thinking: false,
512        supports_adaptive_thinking: false,
513        source_url: OPENAI_PRICING_URL,
514        source_status: SourceStatus::Official,
515        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
516    },
517    ModelCapabilities {
518        provider: "openai",
519        model_id: "gpt-5.2-instant",
520        context_window: Some(400_000),
521        max_output_tokens: Some(128_000),
522        pricing: None,
523        supports_thinking: false,
524        supports_adaptive_thinking: false,
525        source_url: OPENAI_MODELS_URL,
526        source_status: SourceStatus::Unverified,
527        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
528    },
529    ModelCapabilities {
530        provider: "openai",
531        model_id: "gpt-5.2-thinking",
532        context_window: Some(400_000),
533        max_output_tokens: Some(128_000),
534        pricing: None,
535        supports_thinking: true,
536        supports_adaptive_thinking: false,
537        source_url: OPENAI_MODELS_URL,
538        source_status: SourceStatus::Unverified,
539        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
540    },
541    ModelCapabilities {
542        provider: "openai",
543        model_id: "gpt-5.2-pro",
544        context_window: Some(400_000),
545        max_output_tokens: Some(128_000),
546        pricing: Some(Pricing::flat(21.0, 168.0)),
547        supports_thinking: true,
548        supports_adaptive_thinking: false,
549        source_url: OPENAI_GPT52_PRO_URL,
550        source_status: SourceStatus::Official,
551        notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
552    },
553    ModelCapabilities {
554        provider: "openai",
555        model_id: "gpt-5.2-codex",
556        context_window: Some(400_000),
557        max_output_tokens: Some(128_000),
558        pricing: None,
559        supports_thinking: false,
560        supports_adaptive_thinking: false,
561        source_url: OPENAI_MODELS_URL,
562        source_status: SourceStatus::Unverified,
563        notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
564    },
565    ModelCapabilities {
566        provider: "openai",
567        model_id: "o3",
568        context_window: Some(200_000),
569        max_output_tokens: Some(100_000),
570        pricing: Some(Pricing::flat(1.0, 4.0)),
571        supports_thinking: true,
572        supports_adaptive_thinking: false,
573        source_url: OPENAI_PRICING_URL,
574        source_status: SourceStatus::Official,
575        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
576    },
577    ModelCapabilities {
578        provider: "openai",
579        model_id: "o3-mini",
580        context_window: Some(200_000),
581        max_output_tokens: Some(100_000),
582        pricing: Some(Pricing::flat(0.55, 2.20)),
583        supports_thinking: true,
584        supports_adaptive_thinking: false,
585        source_url: OPENAI_PRICING_URL,
586        source_status: SourceStatus::Official,
587        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
588    },
589    ModelCapabilities {
590        provider: "openai",
591        model_id: "o4-mini",
592        context_window: Some(200_000),
593        max_output_tokens: Some(100_000),
594        pricing: Some(Pricing::flat(0.55, 2.20)),
595        supports_thinking: true,
596        supports_adaptive_thinking: false,
597        source_url: OPENAI_PRICING_URL,
598        source_status: SourceStatus::Official,
599        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
600    },
601    ModelCapabilities {
602        provider: "openai",
603        model_id: "o1",
604        context_window: Some(200_000),
605        max_output_tokens: Some(100_000),
606        pricing: Some(Pricing::flat(7.50, 30.0)),
607        supports_thinking: true,
608        supports_adaptive_thinking: false,
609        source_url: OPENAI_PRICING_URL,
610        source_status: SourceStatus::Official,
611        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
612    },
613    ModelCapabilities {
614        provider: "openai",
615        model_id: "o1-mini",
616        context_window: Some(200_000),
617        max_output_tokens: Some(100_000),
618        pricing: Some(Pricing::flat(0.55, 2.20)),
619        supports_thinking: true,
620        supports_adaptive_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: "gpt-4.1",
628        context_window: Some(1_000_000),
629        max_output_tokens: Some(16_384),
630        pricing: Some(Pricing::flat(1.0, 4.0)),
631        supports_thinking: false,
632        supports_adaptive_thinking: false,
633        source_url: OPENAI_PRICING_URL,
634        source_status: SourceStatus::Official,
635        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
636    },
637    ModelCapabilities {
638        provider: "openai",
639        model_id: "gpt-4.1-mini",
640        context_window: Some(1_000_000),
641        max_output_tokens: Some(16_384),
642        pricing: Some(Pricing::flat(0.20, 0.80)),
643        supports_thinking: false,
644        supports_adaptive_thinking: false,
645        source_url: OPENAI_PRICING_URL,
646        source_status: SourceStatus::Official,
647        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
648    },
649    ModelCapabilities {
650        provider: "openai",
651        model_id: "gpt-4.1-nano",
652        context_window: Some(1_000_000),
653        max_output_tokens: Some(16_384),
654        pricing: Some(Pricing::flat(0.05, 0.20)),
655        supports_thinking: false,
656        supports_adaptive_thinking: false,
657        source_url: OPENAI_PRICING_URL,
658        source_status: SourceStatus::Official,
659        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
660    },
661    ModelCapabilities {
662        provider: "openai",
663        model_id: "gpt-4o",
664        context_window: Some(128_000),
665        max_output_tokens: Some(16_384),
666        pricing: Some(Pricing::flat(1.25, 5.0)),
667        supports_thinking: false,
668        supports_adaptive_thinking: false,
669        source_url: OPENAI_PRICING_URL,
670        source_status: SourceStatus::Official,
671        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
672    },
673    ModelCapabilities {
674        provider: "openai",
675        model_id: "gpt-4o-mini",
676        context_window: Some(128_000),
677        max_output_tokens: Some(16_384),
678        pricing: Some(Pricing::flat(0.075, 0.30)),
679        supports_thinking: false,
680        supports_adaptive_thinking: false,
681        source_url: OPENAI_PRICING_URL,
682        source_status: SourceStatus::Official,
683        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
684    },
685    // Gemini
686    ModelCapabilities {
687        provider: "gemini",
688        model_id: "gemini-3.1-pro-preview",
689        context_window: Some(1_048_576),
690        max_output_tokens: Some(65_536),
691        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.")),
692        supports_thinking: true,
693        supports_adaptive_thinking: false,
694        source_url: GOOGLE_PRICING_URL,
695        source_status: SourceStatus::Official,
696        notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
697    },
698    ModelCapabilities {
699        provider: "gemini",
700        model_id: "gemini-3.1-pro",
701        context_window: Some(1_048_576),
702        max_output_tokens: Some(65_536),
703        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.")),
704        supports_thinking: true,
705        supports_adaptive_thinking: false,
706        source_url: GOOGLE_PRICING_URL,
707        source_status: SourceStatus::Derived,
708        notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
709    },
710    ModelCapabilities {
711        provider: "gemini",
712        model_id: "gemini-3.1-flash-lite-preview",
713        context_window: Some(1_048_576),
714        max_output_tokens: Some(65_536),
715        pricing: None,
716        supports_thinking: true,
717        supports_adaptive_thinking: false,
718        source_url: GOOGLE_MODELS_URL,
719        source_status: SourceStatus::Unverified,
720        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
721    },
722    ModelCapabilities {
723        provider: "gemini",
724        model_id: "gemini-3-flash-preview",
725        context_window: Some(1_048_576),
726        max_output_tokens: Some(65_536),
727        pricing: None,
728        supports_thinking: true,
729        supports_adaptive_thinking: false,
730        source_url: GOOGLE_MODELS_URL,
731        source_status: SourceStatus::Unverified,
732        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
733    },
734    ModelCapabilities {
735        provider: "gemini",
736        model_id: "gemini-3.0-flash",
737        context_window: Some(1_048_576),
738        max_output_tokens: Some(65_536),
739        pricing: None,
740        supports_thinking: true,
741        supports_adaptive_thinking: false,
742        source_url: GOOGLE_MODELS_URL,
743        source_status: SourceStatus::Derived,
744        notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
745    },
746    ModelCapabilities {
747        provider: "gemini",
748        model_id: "gemini-3.0-pro",
749        context_window: Some(1_048_576),
750        max_output_tokens: Some(65_536),
751        pricing: None,
752        supports_thinking: true,
753        supports_adaptive_thinking: false,
754        source_url: GOOGLE_MODELS_URL,
755        source_status: SourceStatus::Unverified,
756        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
757    },
758    ModelCapabilities {
759        provider: "gemini",
760        model_id: "gemini-2.5-flash",
761        context_window: Some(1_000_000),
762        max_output_tokens: Some(65_536),
763        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.")),
764        supports_thinking: true,
765        supports_adaptive_thinking: false,
766        source_url: GOOGLE_PRICING_URL,
767        source_status: SourceStatus::Official,
768        notes: Some("Official docs state output pricing includes thinking tokens."),
769    },
770    ModelCapabilities {
771        provider: "gemini",
772        model_id: "gemini-2.5-pro",
773        context_window: Some(1_000_000),
774        max_output_tokens: Some(65_536),
775        pricing: None,
776        supports_thinking: true,
777        supports_adaptive_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-2.0-flash",
785        context_window: Some(1_000_000),
786        max_output_tokens: Some(8_192),
787        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.")),
788        supports_thinking: false,
789        supports_adaptive_thinking: false,
790        source_url: GOOGLE_PRICING_URL,
791        source_status: SourceStatus::Official,
792        notes: None,
793    },
794    ModelCapabilities {
795        provider: "gemini",
796        model_id: "gemini-2.0-flash-lite",
797        context_window: Some(1_000_000),
798        max_output_tokens: Some(8_192),
799        pricing: Some(Pricing::flat(0.075, 0.30)),
800        supports_thinking: false,
801        supports_adaptive_thinking: false,
802        source_url: GOOGLE_PRICING_URL,
803        source_status: SourceStatus::Official,
804        notes: None,
805    },
806    // Open models (z.ai / Moonshot / DeepSeek / MiniMax). All routed through
807    // OpenAIProvider, so provider == "openai" and the model_id is the exact
808    // string the caller passes (OpenRouter slug or native model id).
809    ModelCapabilities {
810        provider: "openai",
811        model_id: "z-ai/glm-5.1",
812        context_window: Some(202_752),
813        max_output_tokens: Some(131_072),
814        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.")),
815        supports_thinking: true,
816        supports_adaptive_thinking: false,
817        source_url: OPENROUTER_GLM51_URL,
818        source_status: SourceStatus::Derived,
819        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."),
820    },
821    ModelCapabilities {
822        provider: "openai",
823        model_id: "glm-5",
824        context_window: Some(200_000),
825        max_output_tokens: Some(131_072),
826        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).")),
827        supports_thinking: true,
828        supports_adaptive_thinking: false,
829        source_url: ZAI_GLM5_PRICING_URL,
830        source_status: SourceStatus::Derived,
831        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."),
832    },
833    ModelCapabilities {
834        provider: "openai",
835        model_id: "moonshotai/kimi-k2.6",
836        context_window: Some(262_144),
837        max_output_tokens: Some(65_536),
838        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.")),
839        supports_thinking: false,
840        supports_adaptive_thinking: false,
841        source_url: OPENROUTER_KIMI_K26_URL,
842        source_status: SourceStatus::Derived,
843        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."),
844    },
845    ModelCapabilities {
846        provider: "openai",
847        model_id: "moonshotai/kimi-k2.5",
848        context_window: Some(262_144),
849        max_output_tokens: Some(32_768),
850        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.")),
851        supports_thinking: false,
852        supports_adaptive_thinking: false,
853        source_url: OPENROUTER_KIMI_K25_URL,
854        source_status: SourceStatus::Derived,
855        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."),
856    },
857    ModelCapabilities {
858        provider: "openai",
859        model_id: "kimi-k2.5",
860        context_window: Some(262_144),
861        max_output_tokens: Some(32_768),
862        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.")),
863        supports_thinking: false,
864        supports_adaptive_thinking: false,
865        source_url: KIMI_K25_AA_URL,
866        source_status: SourceStatus::Unverified,
867        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."),
868    },
869    ModelCapabilities {
870        provider: "openai",
871        model_id: "kimi-k2-thinking",
872        context_window: Some(262_144),
873        max_output_tokens: Some(131_072),
874        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.")),
875        supports_thinking: true,
876        supports_adaptive_thinking: false,
877        source_url: OPENROUTER_KIMI_K2_THINKING_URL,
878        source_status: SourceStatus::Unverified,
879        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."),
880    },
881    ModelCapabilities {
882        provider: "openai",
883        model_id: "deepseek/deepseek-v4-pro",
884        context_window: Some(1_048_576),
885        max_output_tokens: Some(384_000),
886        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.")),
887        supports_thinking: true,
888        supports_adaptive_thinking: false,
889        source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
890        source_status: SourceStatus::Derived,
891        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."),
892    },
893    ModelCapabilities {
894        provider: "openai",
895        model_id: "deepseek-v4-pro",
896        context_window: Some(1_048_576),
897        max_output_tokens: Some(384_000),
898        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.")),
899        supports_thinking: true,
900        supports_adaptive_thinking: false,
901        source_url: DEEPSEEK_PRICING_URL,
902        source_status: SourceStatus::Derived,
903        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."),
904    },
905    ModelCapabilities {
906        provider: "openai",
907        model_id: "deepseek/deepseek-v4-flash",
908        context_window: Some(1_048_576),
909        max_output_tokens: Some(384_000),
910        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.")),
911        supports_thinking: true,
912        supports_adaptive_thinking: false,
913        source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
914        source_status: SourceStatus::Derived,
915        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."),
916    },
917    ModelCapabilities {
918        provider: "openai",
919        model_id: "deepseek-v4-flash",
920        context_window: Some(1_048_576),
921        max_output_tokens: Some(384_000),
922        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.")),
923        supports_thinking: true,
924        supports_adaptive_thinking: false,
925        source_url: DEEPSEEK_PRICING_URL,
926        source_status: SourceStatus::Derived,
927        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."),
928    },
929    ModelCapabilities {
930        provider: "openai",
931        model_id: "MiniMax-M2.5",
932        context_window: Some(204_800),
933        max_output_tokens: Some(131_072),
934        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).")),
935        supports_thinking: true,
936        supports_adaptive_thinking: false,
937        source_url: MINIMAX_PRICING_URL,
938        source_status: SourceStatus::Derived,
939        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."),
940    },
941    ModelCapabilities {
942        provider: "openai",
943        model_id: "minimax/minimax-m2.5",
944        context_window: Some(204_800),
945        max_output_tokens: Some(131_072),
946        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).")),
947        supports_thinking: true,
948        supports_adaptive_thinking: false,
949        source_url: OPENROUTER_MINIMAX_M25_URL,
950        source_status: SourceStatus::Derived,
951        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."),
952    },
953];
954
955#[must_use]
956pub fn get_model_capabilities(
957    provider: &str,
958    model_id: &str,
959) -> Option<&'static ModelCapabilities> {
960    MODEL_CAPABILITIES.iter().find(|caps| {
961        caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
962    })
963}
964
965#[must_use]
966pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
967    get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
968}
969
970#[must_use]
971pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
972    MODEL_CAPABILITIES
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    /// A row where reasoning is DEARER than output — `alibaba/qwen3-32b` in the
980    /// live feed ($2.80/M output, $8.40/M reasoning). Reasoning tokens ride
981    /// inside `output_tokens`, so the whole output band must bill at the higher
982    /// reasoning rate, or the estimate under-bills and the cap can miss.
983    #[test]
984    fn output_bills_at_the_dearer_reasoning_rate() -> anyhow::Result<()> {
985        use anyhow::Context;
986        let pricing = Pricing::flat(0.7, 2.8).with_reasoning(8.4);
987        let usage = Usage {
988            served_speed: None,
989            input_tokens: 1_000_000,
990            output_tokens: 1_000_000,
991            cached_input_tokens: 0,
992            cache_creation_input_tokens: 0,
993        };
994        // 1M input @ $0.70 + 1M output @ max($2.80, $8.40) = 0.7 + 8.4 = 9.1.
995        let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
996        assert!((cost - 9.1).abs() < 1e-9, "unexpected cost: {cost}");
997        Ok(())
998    }
999
1000    /// When reasoning is CHEAPER than output the max leaves output unchanged —
1001    /// billing the whole band at the output rate (a bounded over-estimate for
1002    /// the reasoning tokens, the safe direction).
1003    #[test]
1004    fn output_keeps_the_dearer_output_rate() -> anyhow::Result<()> {
1005        use anyhow::Context;
1006        let pricing = Pricing::flat(1.0, 8.0).with_reasoning(3.0);
1007        let usage = Usage {
1008            served_speed: None,
1009            input_tokens: 0,
1010            output_tokens: 1_000_000,
1011            cached_input_tokens: 0,
1012            cache_creation_input_tokens: 0,
1013        };
1014        // 1M output @ max($8, $3) = $8.
1015        let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1016        assert!((cost - 8.0).abs() < 1e-9, "unexpected cost: {cost}");
1017        Ok(())
1018    }
1019
1020    #[test]
1021    fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
1022        use anyhow::Context;
1023
1024        let caps = get_model_capabilities("anthropic", "claude-fable-5")
1025            .context("claude-fable-5 capabilities missing")?;
1026        assert_eq!(caps.context_window, Some(1_000_000));
1027        assert_eq!(caps.max_output_tokens, Some(128_000));
1028        assert!(caps.supports_thinking);
1029        assert!(caps.supports_adaptive_thinking);
1030        assert_eq!(caps.source_status, SourceStatus::Official);
1031        let pricing = caps.pricing.context("pricing missing")?;
1032        let input = pricing.input.context("input price missing")?;
1033        let output = pricing.output.context("output price missing")?;
1034        assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
1035        assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
1036        Ok(())
1037    }
1038
1039    #[test]
1040    fn test_lookup_anthropic_opus_5() -> anyhow::Result<()> {
1041        use anyhow::Context;
1042
1043        let caps = get_model_capabilities("anthropic", "claude-opus-5")
1044            .context("claude-opus-5 capabilities missing")?;
1045        assert_eq!(caps.context_window, Some(1_000_000));
1046        assert_eq!(caps.max_output_tokens, Some(128_000));
1047        assert!(caps.supports_thinking);
1048        // Like Opus 4.8: manual budget_tokens 400s; adaptive supported, not required.
1049        assert!(caps.supports_adaptive_thinking);
1050        assert_eq!(caps.source_status, SourceStatus::Official);
1051        let pricing = caps.pricing.context("pricing missing")?;
1052        let input = pricing.input.context("input price missing")?;
1053        let output = pricing.output.context("output price missing")?;
1054        assert!((input.usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1055        assert!((output.usd_per_million_tokens - 25.0).abs() < f64::EPSILON);
1056        Ok(())
1057    }
1058
1059    #[test]
1060    fn test_lookup_anthropic_opus_48() {
1061        let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
1062        assert_eq!(caps.context_window, Some(1_000_000));
1063        assert_eq!(caps.max_output_tokens, Some(128_000));
1064        assert!(caps.supports_thinking);
1065        assert!(caps.supports_adaptive_thinking);
1066    }
1067
1068    #[test]
1069    fn test_lookup_anthropic_opus_46() {
1070        let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
1071        assert_eq!(caps.context_window, Some(1_000_000));
1072        assert_eq!(caps.max_output_tokens, Some(128_000));
1073        assert!(caps.supports_adaptive_thinking);
1074    }
1075
1076    #[test]
1077    fn test_lookup_anthropic_sonnet_5() {
1078        let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
1079        assert_eq!(caps.context_window, Some(1_000_000));
1080        assert_eq!(caps.max_output_tokens, Some(128_000));
1081        assert!(caps.supports_thinking);
1082        // Like Opus 4.8: manual budget_tokens 400s; adaptive supported, not required.
1083        assert!(caps.supports_adaptive_thinking);
1084    }
1085
1086    #[test]
1087    fn test_lookup_anthropic_sonnet_46() {
1088        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
1089        assert_eq!(caps.context_window, Some(1_000_000));
1090        assert_eq!(caps.max_output_tokens, Some(64_000));
1091        assert!(caps.supports_adaptive_thinking);
1092    }
1093
1094    #[test]
1095    fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
1096        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
1097        assert!(!caps.supports_adaptive_thinking);
1098    }
1099
1100    #[test]
1101    fn test_lookup_openai_pricing() {
1102        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1103        let pricing = caps.pricing.unwrap();
1104        assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
1105        assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1106    }
1107
1108    #[test]
1109    fn test_lookup_openai_gpt54() {
1110        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1111        assert_eq!(caps.context_window, Some(1_050_000));
1112        assert_eq!(caps.max_output_tokens, Some(128_000));
1113        assert!(caps.supports_thinking);
1114        assert_eq!(caps.source_status, SourceStatus::Official);
1115    }
1116
1117    #[test]
1118    fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
1119        use anyhow::Context;
1120
1121        let caps = get_model_capabilities("openai", "gpt-5.2-pro")
1122            .context("gpt-5.2-pro capabilities missing")?;
1123        assert_eq!(caps.context_window, Some(400_000));
1124        assert_eq!(caps.max_output_tokens, Some(128_000));
1125        assert!(caps.supports_thinking);
1126        assert_eq!(caps.source_status, SourceStatus::Official);
1127        let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
1128        let input = pricing.input.context("input price missing")?;
1129        let output = pricing.output.context("output price missing")?;
1130        assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
1131        assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
1132        Ok(())
1133    }
1134
1135    #[test]
1136    fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
1137        use anyhow::Context as _;
1138
1139        for (model_id, input, cached_input, output, cache_write_note) in [
1140            ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
1141            ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
1142            ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
1143            ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
1144        ] {
1145            let caps = get_model_capabilities("openai", model_id)
1146                .with_context(|| format!("{model_id} capabilities missing"))?;
1147            assert_eq!(caps.context_window, Some(1_050_000));
1148            assert_eq!(caps.max_output_tokens, Some(128_000));
1149            assert!(caps.supports_thinking);
1150            assert!(caps.supports_adaptive_thinking);
1151            assert_eq!(caps.source_status, SourceStatus::Official);
1152            let pricing = caps
1153                .pricing
1154                .with_context(|| format!("{model_id} pricing missing"))?;
1155            assert_eq!(pricing.input, Some(PricePoint::new(input)));
1156            assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
1157            assert_eq!(pricing.output, Some(PricePoint::new(output)));
1158            assert!(pricing.notes.is_some_and(|notes| {
1159                notes.contains(cache_write_note) && notes.contains("more than 272K")
1160            }));
1161        }
1162
1163        Ok(())
1164    }
1165
1166    #[test]
1167    fn test_lookup_openai_gpt53_codex() {
1168        let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
1169        assert_eq!(caps.context_window, Some(400_000));
1170        assert_eq!(caps.max_output_tokens, Some(128_000));
1171        assert!(caps.supports_adaptive_thinking);
1172        assert!(caps.supports_thinking);
1173        assert_eq!(caps.source_status, SourceStatus::Official);
1174    }
1175
1176    #[test]
1177    fn test_lookup_gemini_preview_models() {
1178        let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1179        assert_eq!(flash.context_window, Some(1_048_576));
1180        assert!(flash.supports_thinking);
1181
1182        let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1183        assert_eq!(pro.max_output_tokens, Some(65_536));
1184        assert!(pro.supports_thinking);
1185    }
1186
1187    #[test]
1188    fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1189        // DeepSeek V4 Pro via OpenRouter slug — reasoning model.
1190        let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1191        assert!(deepseek.supports_thinking);
1192        assert_eq!(deepseek.max_output_tokens, Some(384_000));
1193        let pricing = deepseek.pricing.unwrap();
1194        assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1195        assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1196
1197        // z.ai GLM-5.1 via OpenRouter slug — reasoning model.
1198        let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1199        assert!(glm.supports_thinking);
1200        assert_eq!(glm.max_output_tokens, Some(131_072));
1201        let glm_pricing = glm.pricing.unwrap();
1202        assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1203        assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1204
1205        // Kimi K2 Thinking native — reasoning model.
1206        let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1207        assert!(kimi_thinking.supports_thinking);
1208        assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1209        assert!(
1210            kimi_thinking
1211                .pricing
1212                .unwrap()
1213                .output
1214                .unwrap()
1215                .usd_per_million_tokens
1216                > 0.0
1217        );
1218    }
1219
1220    #[test]
1221    fn test_lookup_open_non_reasoning_kimi_models() {
1222        // Kimi K2.6 / K2.5 are registered as non-reasoning coding models.
1223        let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1224        assert!(!k26.supports_thinking);
1225        assert_eq!(k26.max_output_tokens, Some(65_536));
1226        assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1227
1228        let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1229        assert!(!k25_native.supports_thinking);
1230        assert_eq!(k25_native.max_output_tokens, Some(32_768));
1231    }
1232
1233    #[test]
1234    fn test_lookup_all_open_models_resolve() {
1235        // Every model_id below is exactly how the consumer looks them up
1236        // (provider == "openai" for all open routes).
1237        for model_id in [
1238            "z-ai/glm-5.1",
1239            "glm-5",
1240            "moonshotai/kimi-k2.6",
1241            "moonshotai/kimi-k2.5",
1242            "kimi-k2.5",
1243            "kimi-k2-thinking",
1244            "deepseek/deepseek-v4-pro",
1245            "deepseek-v4-pro",
1246            "deepseek/deepseek-v4-flash",
1247            "deepseek-v4-flash",
1248            "MiniMax-M2.5",
1249            "minimax/minimax-m2.5",
1250        ] {
1251            let caps = get_model_capabilities("openai", model_id)
1252                .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1253            assert!(
1254                caps.pricing.is_some(),
1255                "pricing should be populated for {model_id}"
1256            );
1257            assert!(
1258                caps.max_output_tokens.is_some_and(|m| m > 0),
1259                "max_output_tokens should be non-zero for {model_id}"
1260            );
1261            assert!(
1262                caps.context_window.is_some_and(|c| c > 0),
1263                "context_window should be non-zero for {model_id}"
1264            );
1265        }
1266    }
1267
1268    #[test]
1269    fn test_lookup_minimax_native_pricing() {
1270        let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1271        assert!(native.supports_thinking);
1272        let pricing = native.pricing.unwrap();
1273        assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1274        assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1275        // Cache-read is the first-party platform.minimax.io PAYG rate ($0.03/M),
1276        // not the ~$0.155/M that an earlier entry overstated by ~3-5x.
1277        assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1278    }
1279
1280    #[test]
1281    fn test_estimate_cost_usd() {
1282        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1283        let cost = caps
1284            .estimate_cost_usd(&Usage {
1285                served_speed: None,
1286                input_tokens: 2_000,
1287                output_tokens: 1_000,
1288                cached_input_tokens: 0,
1289                cache_creation_input_tokens: 0,
1290            })
1291            .unwrap();
1292        assert!((cost - 0.0075).abs() < f64::EPSILON);
1293    }
1294
1295    #[test]
1296    fn test_estimate_cost_usd_with_cached_input() {
1297        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1298        let cost = caps
1299            .estimate_cost_usd(&Usage {
1300                served_speed: None,
1301                input_tokens: 2_000,
1302                output_tokens: 1_000,
1303                cached_input_tokens: 1_000,
1304                cache_creation_input_tokens: 0,
1305            })
1306            .unwrap();
1307        assert!((cost - 0.01775).abs() < f64::EPSILON);
1308    }
1309}