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-4-8",
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 4.8 pricing matches the Opus 4.6 tier ($5/$25 per 1M); verify exact current SKU mapping before billing-critical use.")),
262        supports_thinking: true,
263        supports_adaptive_thinking: true,
264        source_url: ANTHROPIC_MODELS_URL,
265        source_status: SourceStatus::Derived,
266        notes: Some("Opus 4.8 requires adaptive thinking — `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config."),
267    },
268    ModelCapabilities {
269        provider: "anthropic",
270        model_id: "claude-opus-4-7",
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.7 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.7 requires adaptive thinking — `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config."),
279    },
280    ModelCapabilities {
281        provider: "anthropic",
282        model_id: "claude-opus-4-6",
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.6 pricing from bundled Claude API guidance; 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("Current Anthropic docs show this model alongside 200K/128K markers."),
291    },
292    ModelCapabilities {
293        provider: "anthropic",
294        model_id: "claude-sonnet-5",
295        context_window: Some(1_000_000),
296        max_output_tokens: Some(128_000),
297        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.")),
298        supports_thinking: true,
299        supports_adaptive_thinking: true,
300        source_url: ANTHROPIC_MODELS_URL,
301        source_status: SourceStatus::Official,
302        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)."),
303    },
304    ModelCapabilities {
305        provider: "anthropic",
306        model_id: "claude-sonnet-4-6",
307        context_window: Some(1_000_000),
308        max_output_tokens: Some(64_000),
309        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
310        supports_thinking: true,
311        supports_adaptive_thinking: true,
312        source_url: ANTHROPIC_MODELS_URL,
313        source_status: SourceStatus::Derived,
314        notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
315    },
316    ModelCapabilities {
317        provider: "anthropic",
318        model_id: "claude-sonnet-4-5-20250929",
319        context_window: Some(200_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: false,
324        source_url: ANTHROPIC_MODELS_URL,
325        source_status: SourceStatus::Derived,
326        notes: None,
327    },
328    ModelCapabilities {
329        provider: "anthropic",
330        model_id: "claude-haiku-4-5-20251001",
331        context_window: Some(200_000),
332        max_output_tokens: Some(64_000),
333        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku 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-sonnet-4-20250514",
343        context_window: Some(200_000),
344        max_output_tokens: Some(64_000),
345        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 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-opus-4-20250514",
355        context_window: Some(200_000),
356        max_output_tokens: Some(32_000),
357        pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus 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-3-5-sonnet-20241022",
367        context_window: Some(200_000),
368        max_output_tokens: Some(8_192),
369        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 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-haiku-20241022",
379        context_window: Some(200_000),
380        max_output_tokens: Some(8_192),
381        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku 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    // OpenAI
389    ModelCapabilities {
390        provider: "openai",
391        model_id: "gpt-5.6",
392        context_window: Some(1_050_000),
393        max_output_tokens: Some(128_000),
394        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
395            "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.",
396        )),
397        supports_thinking: true,
398        supports_adaptive_thinking: true,
399        source_url: OPENAI_GPT56_SOL_URL,
400        source_status: SourceStatus::Official,
401        notes: Some("Official alias for GPT-5.6 Sol."),
402    },
403    ModelCapabilities {
404        provider: "openai",
405        model_id: "gpt-5.6-sol",
406        context_window: Some(1_050_000),
407        max_output_tokens: Some(128_000),
408        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
409            "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.",
410        )),
411        supports_thinking: true,
412        supports_adaptive_thinking: true,
413        source_url: OPENAI_GPT56_SOL_URL,
414        source_status: SourceStatus::Official,
415        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
416    },
417    ModelCapabilities {
418        provider: "openai",
419        model_id: "gpt-5.6-terra",
420        context_window: Some(1_050_000),
421        max_output_tokens: Some(128_000),
422        pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
423            "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.",
424        )),
425        supports_thinking: true,
426        supports_adaptive_thinking: true,
427        source_url: OPENAI_GPT56_TERRA_URL,
428        source_status: SourceStatus::Official,
429        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
430    },
431    ModelCapabilities {
432        provider: "openai",
433        model_id: "gpt-5.6-luna",
434        context_window: Some(1_050_000),
435        max_output_tokens: Some(128_000),
436        pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
437            "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.",
438        )),
439        supports_thinking: true,
440        supports_adaptive_thinking: true,
441        source_url: OPENAI_GPT56_LUNA_URL,
442        source_status: SourceStatus::Official,
443        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
444    },
445    ModelCapabilities {
446        provider: "openai",
447        model_id: "gpt-5.4",
448        context_window: Some(1_050_000),
449        max_output_tokens: Some(128_000),
450        pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
451        supports_thinking: true,
452        supports_adaptive_thinking: false,
453        source_url: OPENAI_GPT54_URL,
454        source_status: SourceStatus::Official,
455        notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
456    },
457    ModelCapabilities {
458        provider: "openai",
459        model_id: "gpt-5.3-codex",
460        context_window: Some(400_000),
461        max_output_tokens: Some(128_000),
462        pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
463        supports_thinking: true,
464        supports_adaptive_thinking: true,
465        source_url: OPENAI_GPT53_CODEX_URL,
466        source_status: SourceStatus::Official,
467        notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
468    },
469    ModelCapabilities {
470        provider: "openai",
471        model_id: "gpt-5",
472        context_window: Some(400_000),
473        max_output_tokens: Some(128_000),
474        pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
475        supports_thinking: false,
476        supports_adaptive_thinking: false,
477        source_url: OPENAI_PRICING_URL,
478        source_status: SourceStatus::Official,
479        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
480    },
481    ModelCapabilities {
482        provider: "openai",
483        model_id: "gpt-5-mini",
484        context_window: Some(400_000),
485        max_output_tokens: Some(128_000),
486        pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
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-nano",
496        context_window: Some(400_000),
497        max_output_tokens: Some(128_000),
498        pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
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.2-instant",
508        context_window: Some(400_000),
509        max_output_tokens: Some(128_000),
510        pricing: None,
511        supports_thinking: false,
512        supports_adaptive_thinking: false,
513        source_url: OPENAI_MODELS_URL,
514        source_status: SourceStatus::Unverified,
515        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
516    },
517    ModelCapabilities {
518        provider: "openai",
519        model_id: "gpt-5.2-thinking",
520        context_window: Some(400_000),
521        max_output_tokens: Some(128_000),
522        pricing: None,
523        supports_thinking: true,
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-pro",
532        context_window: Some(400_000),
533        max_output_tokens: Some(128_000),
534        pricing: Some(Pricing::flat(21.0, 168.0)),
535        supports_thinking: true,
536        supports_adaptive_thinking: false,
537        source_url: OPENAI_GPT52_PRO_URL,
538        source_status: SourceStatus::Official,
539        notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
540    },
541    ModelCapabilities {
542        provider: "openai",
543        model_id: "gpt-5.2-codex",
544        context_window: Some(400_000),
545        max_output_tokens: Some(128_000),
546        pricing: None,
547        supports_thinking: false,
548        supports_adaptive_thinking: false,
549        source_url: OPENAI_MODELS_URL,
550        source_status: SourceStatus::Unverified,
551        notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
552    },
553    ModelCapabilities {
554        provider: "openai",
555        model_id: "o3",
556        context_window: Some(200_000),
557        max_output_tokens: Some(100_000),
558        pricing: Some(Pricing::flat(1.0, 4.0)),
559        supports_thinking: true,
560        supports_adaptive_thinking: false,
561        source_url: OPENAI_PRICING_URL,
562        source_status: SourceStatus::Official,
563        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
564    },
565    ModelCapabilities {
566        provider: "openai",
567        model_id: "o3-mini",
568        context_window: Some(200_000),
569        max_output_tokens: Some(100_000),
570        pricing: Some(Pricing::flat(0.55, 2.20)),
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: "o4-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: "o1",
592        context_window: Some(200_000),
593        max_output_tokens: Some(100_000),
594        pricing: Some(Pricing::flat(7.50, 30.0)),
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-mini",
604        context_window: Some(200_000),
605        max_output_tokens: Some(100_000),
606        pricing: Some(Pricing::flat(0.55, 2.20)),
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: "gpt-4.1",
616        context_window: Some(1_000_000),
617        max_output_tokens: Some(16_384),
618        pricing: Some(Pricing::flat(1.0, 4.0)),
619        supports_thinking: false,
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 window from model family docs/notes."),
624    },
625    ModelCapabilities {
626        provider: "openai",
627        model_id: "gpt-4.1-mini",
628        context_window: Some(1_000_000),
629        max_output_tokens: Some(16_384),
630        pricing: Some(Pricing::flat(0.20, 0.80)),
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-nano",
640        context_window: Some(1_000_000),
641        max_output_tokens: Some(16_384),
642        pricing: Some(Pricing::flat(0.05, 0.20)),
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-4o",
652        context_window: Some(128_000),
653        max_output_tokens: Some(16_384),
654        pricing: Some(Pricing::flat(1.25, 5.0)),
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/max output from existing runtime assumptions."),
660    },
661    ModelCapabilities {
662        provider: "openai",
663        model_id: "gpt-4o-mini",
664        context_window: Some(128_000),
665        max_output_tokens: Some(16_384),
666        pricing: Some(Pricing::flat(0.075, 0.30)),
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    // Gemini
674    ModelCapabilities {
675        provider: "gemini",
676        model_id: "gemini-3.1-pro-preview",
677        context_window: Some(1_048_576),
678        max_output_tokens: Some(65_536),
679        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.")),
680        supports_thinking: true,
681        supports_adaptive_thinking: false,
682        source_url: GOOGLE_PRICING_URL,
683        source_status: SourceStatus::Official,
684        notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
685    },
686    ModelCapabilities {
687        provider: "gemini",
688        model_id: "gemini-3.1-pro",
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("Legacy alias retained for compatibility. 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::Derived,
696        notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
697    },
698    ModelCapabilities {
699        provider: "gemini",
700        model_id: "gemini-3.1-flash-lite-preview",
701        context_window: Some(1_048_576),
702        max_output_tokens: Some(65_536),
703        pricing: None,
704        supports_thinking: true,
705        supports_adaptive_thinking: false,
706        source_url: GOOGLE_MODELS_URL,
707        source_status: SourceStatus::Unverified,
708        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
709    },
710    ModelCapabilities {
711        provider: "gemini",
712        model_id: "gemini-3-flash-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.0-flash",
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::Derived,
732        notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
733    },
734    ModelCapabilities {
735        provider: "gemini",
736        model_id: "gemini-3.0-pro",
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::Unverified,
744        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
745    },
746    ModelCapabilities {
747        provider: "gemini",
748        model_id: "gemini-2.5-flash",
749        context_window: Some(1_000_000),
750        max_output_tokens: Some(65_536),
751        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.")),
752        supports_thinking: true,
753        supports_adaptive_thinking: false,
754        source_url: GOOGLE_PRICING_URL,
755        source_status: SourceStatus::Official,
756        notes: Some("Official docs state output pricing includes thinking tokens."),
757    },
758    ModelCapabilities {
759        provider: "gemini",
760        model_id: "gemini-2.5-pro",
761        context_window: Some(1_000_000),
762        max_output_tokens: Some(65_536),
763        pricing: None,
764        supports_thinking: true,
765        supports_adaptive_thinking: false,
766        source_url: GOOGLE_MODELS_URL,
767        source_status: SourceStatus::Unverified,
768        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
769    },
770    ModelCapabilities {
771        provider: "gemini",
772        model_id: "gemini-2.0-flash",
773        context_window: Some(1_000_000),
774        max_output_tokens: Some(8_192),
775        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.")),
776        supports_thinking: false,
777        supports_adaptive_thinking: false,
778        source_url: GOOGLE_PRICING_URL,
779        source_status: SourceStatus::Official,
780        notes: None,
781    },
782    ModelCapabilities {
783        provider: "gemini",
784        model_id: "gemini-2.0-flash-lite",
785        context_window: Some(1_000_000),
786        max_output_tokens: Some(8_192),
787        pricing: Some(Pricing::flat(0.075, 0.30)),
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    // Open models (z.ai / Moonshot / DeepSeek / MiniMax). All routed through
795    // OpenAIProvider, so provider == "openai" and the model_id is the exact
796    // string the caller passes (OpenRouter slug or native model id).
797    ModelCapabilities {
798        provider: "openai",
799        model_id: "z-ai/glm-5.1",
800        context_window: Some(202_752),
801        max_output_tokens: Some(131_072),
802        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.")),
803        supports_thinking: true,
804        supports_adaptive_thinking: false,
805        source_url: OPENROUTER_GLM51_URL,
806        source_status: SourceStatus::Derived,
807        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."),
808    },
809    ModelCapabilities {
810        provider: "openai",
811        model_id: "glm-5",
812        context_window: Some(200_000),
813        max_output_tokens: Some(131_072),
814        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).")),
815        supports_thinking: true,
816        supports_adaptive_thinking: false,
817        source_url: ZAI_GLM5_PRICING_URL,
818        source_status: SourceStatus::Derived,
819        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."),
820    },
821    ModelCapabilities {
822        provider: "openai",
823        model_id: "moonshotai/kimi-k2.6",
824        context_window: Some(262_144),
825        max_output_tokens: Some(65_536),
826        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.")),
827        supports_thinking: false,
828        supports_adaptive_thinking: false,
829        source_url: OPENROUTER_KIMI_K26_URL,
830        source_status: SourceStatus::Derived,
831        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."),
832    },
833    ModelCapabilities {
834        provider: "openai",
835        model_id: "moonshotai/kimi-k2.5",
836        context_window: Some(262_144),
837        max_output_tokens: Some(32_768),
838        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.")),
839        supports_thinking: false,
840        supports_adaptive_thinking: false,
841        source_url: OPENROUTER_KIMI_K25_URL,
842        source_status: SourceStatus::Derived,
843        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."),
844    },
845    ModelCapabilities {
846        provider: "openai",
847        model_id: "kimi-k2.5",
848        context_window: Some(262_144),
849        max_output_tokens: Some(32_768),
850        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.")),
851        supports_thinking: false,
852        supports_adaptive_thinking: false,
853        source_url: KIMI_K25_AA_URL,
854        source_status: SourceStatus::Unverified,
855        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."),
856    },
857    ModelCapabilities {
858        provider: "openai",
859        model_id: "kimi-k2-thinking",
860        context_window: Some(262_144),
861        max_output_tokens: Some(131_072),
862        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.")),
863        supports_thinking: true,
864        supports_adaptive_thinking: false,
865        source_url: OPENROUTER_KIMI_K2_THINKING_URL,
866        source_status: SourceStatus::Unverified,
867        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."),
868    },
869    ModelCapabilities {
870        provider: "openai",
871        model_id: "deepseek/deepseek-v4-pro",
872        context_window: Some(1_048_576),
873        max_output_tokens: Some(384_000),
874        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.")),
875        supports_thinking: true,
876        supports_adaptive_thinking: false,
877        source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
878        source_status: SourceStatus::Derived,
879        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."),
880    },
881    ModelCapabilities {
882        provider: "openai",
883        model_id: "deepseek-v4-pro",
884        context_window: Some(1_048_576),
885        max_output_tokens: Some(384_000),
886        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.")),
887        supports_thinking: true,
888        supports_adaptive_thinking: false,
889        source_url: DEEPSEEK_PRICING_URL,
890        source_status: SourceStatus::Derived,
891        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."),
892    },
893    ModelCapabilities {
894        provider: "openai",
895        model_id: "deepseek/deepseek-v4-flash",
896        context_window: Some(1_048_576),
897        max_output_tokens: Some(384_000),
898        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.")),
899        supports_thinking: true,
900        supports_adaptive_thinking: false,
901        source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
902        source_status: SourceStatus::Derived,
903        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."),
904    },
905    ModelCapabilities {
906        provider: "openai",
907        model_id: "deepseek-v4-flash",
908        context_window: Some(1_048_576),
909        max_output_tokens: Some(384_000),
910        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.")),
911        supports_thinking: true,
912        supports_adaptive_thinking: false,
913        source_url: DEEPSEEK_PRICING_URL,
914        source_status: SourceStatus::Derived,
915        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."),
916    },
917    ModelCapabilities {
918        provider: "openai",
919        model_id: "MiniMax-M2.5",
920        context_window: Some(204_800),
921        max_output_tokens: Some(131_072),
922        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).")),
923        supports_thinking: true,
924        supports_adaptive_thinking: false,
925        source_url: MINIMAX_PRICING_URL,
926        source_status: SourceStatus::Derived,
927        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."),
928    },
929    ModelCapabilities {
930        provider: "openai",
931        model_id: "minimax/minimax-m2.5",
932        context_window: Some(204_800),
933        max_output_tokens: Some(131_072),
934        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).")),
935        supports_thinking: true,
936        supports_adaptive_thinking: false,
937        source_url: OPENROUTER_MINIMAX_M25_URL,
938        source_status: SourceStatus::Derived,
939        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."),
940    },
941];
942
943#[must_use]
944pub fn get_model_capabilities(
945    provider: &str,
946    model_id: &str,
947) -> Option<&'static ModelCapabilities> {
948    MODEL_CAPABILITIES.iter().find(|caps| {
949        caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
950    })
951}
952
953#[must_use]
954pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
955    get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
956}
957
958#[must_use]
959pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
960    MODEL_CAPABILITIES
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    /// A row where reasoning is DEARER than output — `alibaba/qwen3-32b` in the
968    /// live feed ($2.80/M output, $8.40/M reasoning). Reasoning tokens ride
969    /// inside `output_tokens`, so the whole output band must bill at the higher
970    /// reasoning rate, or the estimate under-bills and the cap can miss.
971    #[test]
972    fn output_bills_at_the_dearer_reasoning_rate() -> anyhow::Result<()> {
973        use anyhow::Context;
974        let pricing = Pricing::flat(0.7, 2.8).with_reasoning(8.4);
975        let usage = Usage {
976            input_tokens: 1_000_000,
977            output_tokens: 1_000_000,
978            cached_input_tokens: 0,
979            cache_creation_input_tokens: 0,
980        };
981        // 1M input @ $0.70 + 1M output @ max($2.80, $8.40) = 0.7 + 8.4 = 9.1.
982        let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
983        assert!((cost - 9.1).abs() < 1e-9, "unexpected cost: {cost}");
984        Ok(())
985    }
986
987    /// When reasoning is CHEAPER than output the max leaves output unchanged —
988    /// billing the whole band at the output rate (a bounded over-estimate for
989    /// the reasoning tokens, the safe direction).
990    #[test]
991    fn output_keeps_the_dearer_output_rate() -> anyhow::Result<()> {
992        use anyhow::Context;
993        let pricing = Pricing::flat(1.0, 8.0).with_reasoning(3.0);
994        let usage = Usage {
995            input_tokens: 0,
996            output_tokens: 1_000_000,
997            cached_input_tokens: 0,
998            cache_creation_input_tokens: 0,
999        };
1000        // 1M output @ max($8, $3) = $8.
1001        let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1002        assert!((cost - 8.0).abs() < 1e-9, "unexpected cost: {cost}");
1003        Ok(())
1004    }
1005
1006    #[test]
1007    fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
1008        use anyhow::Context;
1009
1010        let caps = get_model_capabilities("anthropic", "claude-fable-5")
1011            .context("claude-fable-5 capabilities missing")?;
1012        assert_eq!(caps.context_window, Some(1_000_000));
1013        assert_eq!(caps.max_output_tokens, Some(128_000));
1014        assert!(caps.supports_thinking);
1015        assert!(caps.supports_adaptive_thinking);
1016        assert_eq!(caps.source_status, SourceStatus::Official);
1017        let pricing = caps.pricing.context("pricing missing")?;
1018        let input = pricing.input.context("input price missing")?;
1019        let output = pricing.output.context("output price missing")?;
1020        assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
1021        assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
1022        Ok(())
1023    }
1024
1025    #[test]
1026    fn test_lookup_anthropic_opus_48() {
1027        let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
1028        assert_eq!(caps.context_window, Some(1_000_000));
1029        assert_eq!(caps.max_output_tokens, Some(128_000));
1030        assert!(caps.supports_thinking);
1031        assert!(caps.supports_adaptive_thinking);
1032    }
1033
1034    #[test]
1035    fn test_lookup_anthropic_opus_46() {
1036        let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
1037        assert_eq!(caps.context_window, Some(1_000_000));
1038        assert_eq!(caps.max_output_tokens, Some(128_000));
1039        assert!(caps.supports_adaptive_thinking);
1040    }
1041
1042    #[test]
1043    fn test_lookup_anthropic_sonnet_5() {
1044        let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
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        // Adaptive-only (like Opus 4.8): manual budget_tokens 400s; adaptive is required.
1049        assert!(caps.supports_adaptive_thinking);
1050    }
1051
1052    #[test]
1053    fn test_lookup_anthropic_sonnet_46() {
1054        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
1055        assert_eq!(caps.context_window, Some(1_000_000));
1056        assert_eq!(caps.max_output_tokens, Some(64_000));
1057        assert!(caps.supports_adaptive_thinking);
1058    }
1059
1060    #[test]
1061    fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
1062        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
1063        assert!(!caps.supports_adaptive_thinking);
1064    }
1065
1066    #[test]
1067    fn test_lookup_openai_pricing() {
1068        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1069        let pricing = caps.pricing.unwrap();
1070        assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
1071        assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1072    }
1073
1074    #[test]
1075    fn test_lookup_openai_gpt54() {
1076        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1077        assert_eq!(caps.context_window, Some(1_050_000));
1078        assert_eq!(caps.max_output_tokens, Some(128_000));
1079        assert!(caps.supports_thinking);
1080        assert_eq!(caps.source_status, SourceStatus::Official);
1081    }
1082
1083    #[test]
1084    fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
1085        use anyhow::Context;
1086
1087        let caps = get_model_capabilities("openai", "gpt-5.2-pro")
1088            .context("gpt-5.2-pro capabilities missing")?;
1089        assert_eq!(caps.context_window, Some(400_000));
1090        assert_eq!(caps.max_output_tokens, Some(128_000));
1091        assert!(caps.supports_thinking);
1092        assert_eq!(caps.source_status, SourceStatus::Official);
1093        let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
1094        let input = pricing.input.context("input price missing")?;
1095        let output = pricing.output.context("output price missing")?;
1096        assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
1097        assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
1098        Ok(())
1099    }
1100
1101    #[test]
1102    fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
1103        use anyhow::Context as _;
1104
1105        for (model_id, input, cached_input, output, cache_write_note) in [
1106            ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
1107            ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
1108            ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
1109            ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
1110        ] {
1111            let caps = get_model_capabilities("openai", model_id)
1112                .with_context(|| format!("{model_id} capabilities missing"))?;
1113            assert_eq!(caps.context_window, Some(1_050_000));
1114            assert_eq!(caps.max_output_tokens, Some(128_000));
1115            assert!(caps.supports_thinking);
1116            assert!(caps.supports_adaptive_thinking);
1117            assert_eq!(caps.source_status, SourceStatus::Official);
1118            let pricing = caps
1119                .pricing
1120                .with_context(|| format!("{model_id} pricing missing"))?;
1121            assert_eq!(pricing.input, Some(PricePoint::new(input)));
1122            assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
1123            assert_eq!(pricing.output, Some(PricePoint::new(output)));
1124            assert!(pricing.notes.is_some_and(|notes| {
1125                notes.contains(cache_write_note) && notes.contains("more than 272K")
1126            }));
1127        }
1128
1129        Ok(())
1130    }
1131
1132    #[test]
1133    fn test_lookup_openai_gpt53_codex() {
1134        let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
1135        assert_eq!(caps.context_window, Some(400_000));
1136        assert_eq!(caps.max_output_tokens, Some(128_000));
1137        assert!(caps.supports_adaptive_thinking);
1138        assert!(caps.supports_thinking);
1139        assert_eq!(caps.source_status, SourceStatus::Official);
1140    }
1141
1142    #[test]
1143    fn test_lookup_gemini_preview_models() {
1144        let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1145        assert_eq!(flash.context_window, Some(1_048_576));
1146        assert!(flash.supports_thinking);
1147
1148        let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1149        assert_eq!(pro.max_output_tokens, Some(65_536));
1150        assert!(pro.supports_thinking);
1151    }
1152
1153    #[test]
1154    fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1155        // DeepSeek V4 Pro via OpenRouter slug — reasoning model.
1156        let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1157        assert!(deepseek.supports_thinking);
1158        assert_eq!(deepseek.max_output_tokens, Some(384_000));
1159        let pricing = deepseek.pricing.unwrap();
1160        assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1161        assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1162
1163        // z.ai GLM-5.1 via OpenRouter slug — reasoning model.
1164        let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1165        assert!(glm.supports_thinking);
1166        assert_eq!(glm.max_output_tokens, Some(131_072));
1167        let glm_pricing = glm.pricing.unwrap();
1168        assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1169        assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1170
1171        // Kimi K2 Thinking native — reasoning model.
1172        let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1173        assert!(kimi_thinking.supports_thinking);
1174        assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1175        assert!(
1176            kimi_thinking
1177                .pricing
1178                .unwrap()
1179                .output
1180                .unwrap()
1181                .usd_per_million_tokens
1182                > 0.0
1183        );
1184    }
1185
1186    #[test]
1187    fn test_lookup_open_non_reasoning_kimi_models() {
1188        // Kimi K2.6 / K2.5 are registered as non-reasoning coding models.
1189        let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1190        assert!(!k26.supports_thinking);
1191        assert_eq!(k26.max_output_tokens, Some(65_536));
1192        assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1193
1194        let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1195        assert!(!k25_native.supports_thinking);
1196        assert_eq!(k25_native.max_output_tokens, Some(32_768));
1197    }
1198
1199    #[test]
1200    fn test_lookup_all_open_models_resolve() {
1201        // Every model_id below is exactly how the consumer looks them up
1202        // (provider == "openai" for all open routes).
1203        for model_id in [
1204            "z-ai/glm-5.1",
1205            "glm-5",
1206            "moonshotai/kimi-k2.6",
1207            "moonshotai/kimi-k2.5",
1208            "kimi-k2.5",
1209            "kimi-k2-thinking",
1210            "deepseek/deepseek-v4-pro",
1211            "deepseek-v4-pro",
1212            "deepseek/deepseek-v4-flash",
1213            "deepseek-v4-flash",
1214            "MiniMax-M2.5",
1215            "minimax/minimax-m2.5",
1216        ] {
1217            let caps = get_model_capabilities("openai", model_id)
1218                .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1219            assert!(
1220                caps.pricing.is_some(),
1221                "pricing should be populated for {model_id}"
1222            );
1223            assert!(
1224                caps.max_output_tokens.is_some_and(|m| m > 0),
1225                "max_output_tokens should be non-zero for {model_id}"
1226            );
1227            assert!(
1228                caps.context_window.is_some_and(|c| c > 0),
1229                "context_window should be non-zero for {model_id}"
1230            );
1231        }
1232    }
1233
1234    #[test]
1235    fn test_lookup_minimax_native_pricing() {
1236        let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1237        assert!(native.supports_thinking);
1238        let pricing = native.pricing.unwrap();
1239        assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1240        assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1241        // Cache-read is the first-party platform.minimax.io PAYG rate ($0.03/M),
1242        // not the ~$0.155/M that an earlier entry overstated by ~3-5x.
1243        assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1244    }
1245
1246    #[test]
1247    fn test_estimate_cost_usd() {
1248        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1249        let cost = caps
1250            .estimate_cost_usd(&Usage {
1251                input_tokens: 2_000,
1252                output_tokens: 1_000,
1253                cached_input_tokens: 0,
1254                cache_creation_input_tokens: 0,
1255            })
1256            .unwrap();
1257        assert!((cost - 0.0075).abs() < f64::EPSILON);
1258    }
1259
1260    #[test]
1261    fn test_estimate_cost_usd_with_cached_input() {
1262        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1263        let cost = caps
1264            .estimate_cost_usd(&Usage {
1265                input_tokens: 2_000,
1266                output_tokens: 1_000,
1267                cached_input_tokens: 1_000,
1268                cache_creation_input_tokens: 0,
1269            })
1270            .unwrap();
1271        assert!((cost - 0.01775).abs() < f64::EPSILON);
1272    }
1273}