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    pub cached_input: Option<PricePoint>,
35    pub notes: Option<&'static str>,
36}
37
38impl Pricing {
39    #[must_use]
40    pub const fn flat(input: f64, output: f64) -> Self {
41        Self {
42            input: Some(PricePoint::new(input)),
43            output: Some(PricePoint::new(output)),
44            cached_input: None,
45            notes: None,
46        }
47    }
48
49    #[must_use]
50    pub const fn flat_with_cached(input: f64, output: f64, cached_input: f64) -> Self {
51        Self {
52            input: Some(PricePoint::new(input)),
53            output: Some(PricePoint::new(output)),
54            cached_input: Some(PricePoint::new(cached_input)),
55            notes: None,
56        }
57    }
58
59    #[must_use]
60    pub const fn with_notes(mut self, notes: &'static str) -> Self {
61        self.notes = Some(notes);
62        self
63    }
64
65    #[must_use]
66    pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
67        let cached_input_tokens = usage.cached_input_tokens.min(usage.input_tokens);
68        let uncached_input_tokens = usage.input_tokens.saturating_sub(cached_input_tokens);
69
70        let input = match (self.input, self.cached_input) {
71            (Some(input), Some(cached_input)) => Some(
72                input.estimate_cost_usd(uncached_input_tokens)
73                    + cached_input.estimate_cost_usd(cached_input_tokens),
74            ),
75            (Some(input), None) => Some(input.estimate_cost_usd(usage.input_tokens)),
76            (None, Some(cached_input)) => Some(cached_input.estimate_cost_usd(cached_input_tokens)),
77            (None, None) => None,
78        };
79        let output = self
80            .output
81            .map(|p| p.estimate_cost_usd(usage.output_tokens));
82        match (input, output) {
83            (Some(input), Some(output)) => Some(input + output),
84            (Some(input), None) => Some(input),
85            (None, Some(output)) => Some(output),
86            (None, None) => None,
87        }
88    }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct ModelCapabilities {
93    pub provider: &'static str,
94    pub model_id: &'static str,
95    pub context_window: Option<u32>,
96    pub max_output_tokens: Option<u32>,
97    pub pricing: Option<Pricing>,
98    pub supports_thinking: bool,
99    pub supports_adaptive_thinking: bool,
100    pub source_url: &'static str,
101    pub source_status: SourceStatus,
102    pub notes: Option<&'static str>,
103}
104
105impl ModelCapabilities {
106    #[must_use]
107    pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
108        self.pricing
109            .as_ref()
110            .and_then(|p| p.estimate_cost_usd(usage))
111    }
112}
113
114const ANTHROPIC_MODELS_URL: &str =
115    "https://docs.anthropic.com/en/docs/about-claude/models/all-models";
116const OPENAI_MODELS_URL: &str = "https://developers.openai.com/api/docs/models";
117const OPENAI_PRICING_URL: &str = "https://developers.openai.com/api/docs/pricing";
118const OPENAI_GPT56_SOL_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-sol";
119const OPENAI_GPT56_TERRA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-terra";
120const OPENAI_GPT56_LUNA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-luna";
121const OPENAI_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
122const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
123const OPENAI_GPT52_PRO_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.2-pro";
124const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
125const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
126
127// Open-model routes. All reached through OpenAIProvider (provider()=="openai"),
128// whether via OpenRouter slugs or the native z.ai / Moonshot / MiniMax base URLs.
129const OPENROUTER_GLM51_URL: &str = "https://openrouter.ai/z-ai/glm-5.1";
130const ZAI_GLM5_PRICING_URL: &str = "https://docs.z.ai/guides/overview/pricing";
131const OPENROUTER_KIMI_K26_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.6";
132const OPENROUTER_KIMI_K25_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.5";
133const KIMI_K25_AA_URL: &str = "https://artificialanalysis.ai/models/kimi-k2-5";
134const OPENROUTER_KIMI_K2_THINKING_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2-thinking";
135const OPENROUTER_DEEPSEEK_V4_PRO_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-pro";
136const OPENROUTER_DEEPSEEK_V4_FLASH_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-flash";
137const DEEPSEEK_PRICING_URL: &str = "https://api-docs.deepseek.com/quick_start/pricing";
138const MINIMAX_PRICING_URL: &str = "https://platform.minimax.io/docs/guides/pricing-paygo";
139const OPENROUTER_MINIMAX_M25_URL: &str = "https://openrouter.ai/minimax/minimax-m2.5";
140
141const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
142    // Anthropic
143    ModelCapabilities {
144        provider: "anthropic",
145        model_id: "claude-fable-5",
146        context_window: Some(1_000_000),
147        max_output_tokens: Some(128_000),
148        pricing: Some(Pricing::flat(10.0, 50.0).with_notes("Anthropic Fable 5 official pricing: $10 input / $50 output per 1M tokens.")),
149        supports_thinking: true,
150        supports_adaptive_thinking: true,
151        source_url: ANTHROPIC_MODELS_URL,
152        source_status: SourceStatus::Official,
153        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."),
154    },
155    ModelCapabilities {
156        provider: "anthropic",
157        model_id: "claude-opus-4-8",
158        context_window: Some(1_000_000),
159        max_output_tokens: Some(128_000),
160        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.")),
161        supports_thinking: true,
162        supports_adaptive_thinking: true,
163        source_url: ANTHROPIC_MODELS_URL,
164        source_status: SourceStatus::Derived,
165        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."),
166    },
167    ModelCapabilities {
168        provider: "anthropic",
169        model_id: "claude-opus-4-7",
170        context_window: Some(1_000_000),
171        max_output_tokens: Some(128_000),
172        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.")),
173        supports_thinking: true,
174        supports_adaptive_thinking: true,
175        source_url: ANTHROPIC_MODELS_URL,
176        source_status: SourceStatus::Derived,
177        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."),
178    },
179    ModelCapabilities {
180        provider: "anthropic",
181        model_id: "claude-opus-4-6",
182        context_window: Some(1_000_000),
183        max_output_tokens: Some(128_000),
184        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.")),
185        supports_thinking: true,
186        supports_adaptive_thinking: true,
187        source_url: ANTHROPIC_MODELS_URL,
188        source_status: SourceStatus::Derived,
189        notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
190    },
191    ModelCapabilities {
192        provider: "anthropic",
193        model_id: "claude-sonnet-5",
194        context_window: Some(1_000_000),
195        max_output_tokens: Some(128_000),
196        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.")),
197        supports_thinking: true,
198        supports_adaptive_thinking: true,
199        source_url: ANTHROPIC_MODELS_URL,
200        source_status: SourceStatus::Official,
201        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)."),
202    },
203    ModelCapabilities {
204        provider: "anthropic",
205        model_id: "claude-sonnet-4-6",
206        context_window: Some(1_000_000),
207        max_output_tokens: Some(64_000),
208        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
209        supports_thinking: true,
210        supports_adaptive_thinking: true,
211        source_url: ANTHROPIC_MODELS_URL,
212        source_status: SourceStatus::Derived,
213        notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
214    },
215    ModelCapabilities {
216        provider: "anthropic",
217        model_id: "claude-sonnet-4-5-20250929",
218        context_window: Some(200_000),
219        max_output_tokens: Some(64_000),
220        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
221        supports_thinking: true,
222        supports_adaptive_thinking: false,
223        source_url: ANTHROPIC_MODELS_URL,
224        source_status: SourceStatus::Derived,
225        notes: None,
226    },
227    ModelCapabilities {
228        provider: "anthropic",
229        model_id: "claude-haiku-4-5-20251001",
230        context_window: Some(200_000),
231        max_output_tokens: Some(64_000),
232        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
233        supports_thinking: true,
234        supports_adaptive_thinking: false,
235        source_url: ANTHROPIC_MODELS_URL,
236        source_status: SourceStatus::Derived,
237        notes: None,
238    },
239    ModelCapabilities {
240        provider: "anthropic",
241        model_id: "claude-sonnet-4-20250514",
242        context_window: Some(200_000),
243        max_output_tokens: Some(64_000),
244        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
245        supports_thinking: true,
246        supports_adaptive_thinking: false,
247        source_url: ANTHROPIC_MODELS_URL,
248        source_status: SourceStatus::Derived,
249        notes: None,
250    },
251    ModelCapabilities {
252        provider: "anthropic",
253        model_id: "claude-opus-4-20250514",
254        context_window: Some(200_000),
255        max_output_tokens: Some(32_000),
256        pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
257        supports_thinking: true,
258        supports_adaptive_thinking: false,
259        source_url: ANTHROPIC_MODELS_URL,
260        source_status: SourceStatus::Derived,
261        notes: None,
262    },
263    ModelCapabilities {
264        provider: "anthropic",
265        model_id: "claude-3-5-sonnet-20241022",
266        context_window: Some(200_000),
267        max_output_tokens: Some(8_192),
268        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
269        supports_thinking: true,
270        supports_adaptive_thinking: false,
271        source_url: ANTHROPIC_MODELS_URL,
272        source_status: SourceStatus::Derived,
273        notes: None,
274    },
275    ModelCapabilities {
276        provider: "anthropic",
277        model_id: "claude-3-5-haiku-20241022",
278        context_window: Some(200_000),
279        max_output_tokens: Some(8_192),
280        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
281        supports_thinking: true,
282        supports_adaptive_thinking: false,
283        source_url: ANTHROPIC_MODELS_URL,
284        source_status: SourceStatus::Derived,
285        notes: None,
286    },
287    // OpenAI
288    ModelCapabilities {
289        provider: "openai",
290        model_id: "gpt-5.6",
291        context_window: Some(1_050_000),
292        max_output_tokens: Some(128_000),
293        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
294            "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.",
295        )),
296        supports_thinking: true,
297        supports_adaptive_thinking: true,
298        source_url: OPENAI_GPT56_SOL_URL,
299        source_status: SourceStatus::Official,
300        notes: Some("Official alias for GPT-5.6 Sol."),
301    },
302    ModelCapabilities {
303        provider: "openai",
304        model_id: "gpt-5.6-sol",
305        context_window: Some(1_050_000),
306        max_output_tokens: Some(128_000),
307        pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
308            "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.",
309        )),
310        supports_thinking: true,
311        supports_adaptive_thinking: true,
312        source_url: OPENAI_GPT56_SOL_URL,
313        source_status: SourceStatus::Official,
314        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
315    },
316    ModelCapabilities {
317        provider: "openai",
318        model_id: "gpt-5.6-terra",
319        context_window: Some(1_050_000),
320        max_output_tokens: Some(128_000),
321        pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
322            "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.",
323        )),
324        supports_thinking: true,
325        supports_adaptive_thinking: true,
326        source_url: OPENAI_GPT56_TERRA_URL,
327        source_status: SourceStatus::Official,
328        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
329    },
330    ModelCapabilities {
331        provider: "openai",
332        model_id: "gpt-5.6-luna",
333        context_window: Some(1_050_000),
334        max_output_tokens: Some(128_000),
335        pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
336            "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.",
337        )),
338        supports_thinking: true,
339        supports_adaptive_thinking: true,
340        source_url: OPENAI_GPT56_LUNA_URL,
341        source_status: SourceStatus::Official,
342        notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
343    },
344    ModelCapabilities {
345        provider: "openai",
346        model_id: "gpt-5.4",
347        context_window: Some(1_050_000),
348        max_output_tokens: Some(128_000),
349        pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
350        supports_thinking: true,
351        supports_adaptive_thinking: false,
352        source_url: OPENAI_GPT54_URL,
353        source_status: SourceStatus::Official,
354        notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
355    },
356    ModelCapabilities {
357        provider: "openai",
358        model_id: "gpt-5.3-codex",
359        context_window: Some(400_000),
360        max_output_tokens: Some(128_000),
361        pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
362        supports_thinking: true,
363        supports_adaptive_thinking: true,
364        source_url: OPENAI_GPT53_CODEX_URL,
365        source_status: SourceStatus::Official,
366        notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
367    },
368    ModelCapabilities {
369        provider: "openai",
370        model_id: "gpt-5",
371        context_window: Some(400_000),
372        max_output_tokens: Some(128_000),
373        pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
374        supports_thinking: false,
375        supports_adaptive_thinking: false,
376        source_url: OPENAI_PRICING_URL,
377        source_status: SourceStatus::Official,
378        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
379    },
380    ModelCapabilities {
381        provider: "openai",
382        model_id: "gpt-5-mini",
383        context_window: Some(400_000),
384        max_output_tokens: Some(128_000),
385        pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
386        supports_thinking: false,
387        supports_adaptive_thinking: false,
388        source_url: OPENAI_PRICING_URL,
389        source_status: SourceStatus::Official,
390        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
391    },
392    ModelCapabilities {
393        provider: "openai",
394        model_id: "gpt-5-nano",
395        context_window: Some(400_000),
396        max_output_tokens: Some(128_000),
397        pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
398        supports_thinking: false,
399        supports_adaptive_thinking: false,
400        source_url: OPENAI_PRICING_URL,
401        source_status: SourceStatus::Official,
402        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
403    },
404    ModelCapabilities {
405        provider: "openai",
406        model_id: "gpt-5.2-instant",
407        context_window: Some(400_000),
408        max_output_tokens: Some(128_000),
409        pricing: None,
410        supports_thinking: false,
411        supports_adaptive_thinking: false,
412        source_url: OPENAI_MODELS_URL,
413        source_status: SourceStatus::Unverified,
414        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
415    },
416    ModelCapabilities {
417        provider: "openai",
418        model_id: "gpt-5.2-thinking",
419        context_window: Some(400_000),
420        max_output_tokens: Some(128_000),
421        pricing: None,
422        supports_thinking: true,
423        supports_adaptive_thinking: false,
424        source_url: OPENAI_MODELS_URL,
425        source_status: SourceStatus::Unverified,
426        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
427    },
428    ModelCapabilities {
429        provider: "openai",
430        model_id: "gpt-5.2-pro",
431        context_window: Some(400_000),
432        max_output_tokens: Some(128_000),
433        pricing: Some(Pricing::flat(21.0, 168.0)),
434        supports_thinking: true,
435        supports_adaptive_thinking: false,
436        source_url: OPENAI_GPT52_PRO_URL,
437        source_status: SourceStatus::Official,
438        notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
439    },
440    ModelCapabilities {
441        provider: "openai",
442        model_id: "gpt-5.2-codex",
443        context_window: Some(400_000),
444        max_output_tokens: Some(128_000),
445        pricing: None,
446        supports_thinking: false,
447        supports_adaptive_thinking: false,
448        source_url: OPENAI_MODELS_URL,
449        source_status: SourceStatus::Unverified,
450        notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
451    },
452    ModelCapabilities {
453        provider: "openai",
454        model_id: "o3",
455        context_window: Some(200_000),
456        max_output_tokens: Some(100_000),
457        pricing: Some(Pricing::flat(1.0, 4.0)),
458        supports_thinking: true,
459        supports_adaptive_thinking: false,
460        source_url: OPENAI_PRICING_URL,
461        source_status: SourceStatus::Official,
462        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
463    },
464    ModelCapabilities {
465        provider: "openai",
466        model_id: "o3-mini",
467        context_window: Some(200_000),
468        max_output_tokens: Some(100_000),
469        pricing: Some(Pricing::flat(0.55, 2.20)),
470        supports_thinking: true,
471        supports_adaptive_thinking: false,
472        source_url: OPENAI_PRICING_URL,
473        source_status: SourceStatus::Official,
474        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
475    },
476    ModelCapabilities {
477        provider: "openai",
478        model_id: "o4-mini",
479        context_window: Some(200_000),
480        max_output_tokens: Some(100_000),
481        pricing: Some(Pricing::flat(0.55, 2.20)),
482        supports_thinking: true,
483        supports_adaptive_thinking: false,
484        source_url: OPENAI_PRICING_URL,
485        source_status: SourceStatus::Official,
486        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
487    },
488    ModelCapabilities {
489        provider: "openai",
490        model_id: "o1",
491        context_window: Some(200_000),
492        max_output_tokens: Some(100_000),
493        pricing: Some(Pricing::flat(7.50, 30.0)),
494        supports_thinking: true,
495        supports_adaptive_thinking: false,
496        source_url: OPENAI_PRICING_URL,
497        source_status: SourceStatus::Official,
498        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
499    },
500    ModelCapabilities {
501        provider: "openai",
502        model_id: "o1-mini",
503        context_window: Some(200_000),
504        max_output_tokens: Some(100_000),
505        pricing: Some(Pricing::flat(0.55, 2.20)),
506        supports_thinking: true,
507        supports_adaptive_thinking: false,
508        source_url: OPENAI_PRICING_URL,
509        source_status: SourceStatus::Official,
510        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
511    },
512    ModelCapabilities {
513        provider: "openai",
514        model_id: "gpt-4.1",
515        context_window: Some(1_000_000),
516        max_output_tokens: Some(16_384),
517        pricing: Some(Pricing::flat(1.0, 4.0)),
518        supports_thinking: false,
519        supports_adaptive_thinking: false,
520        source_url: OPENAI_PRICING_URL,
521        source_status: SourceStatus::Official,
522        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
523    },
524    ModelCapabilities {
525        provider: "openai",
526        model_id: "gpt-4.1-mini",
527        context_window: Some(1_000_000),
528        max_output_tokens: Some(16_384),
529        pricing: Some(Pricing::flat(0.20, 0.80)),
530        supports_thinking: false,
531        supports_adaptive_thinking: false,
532        source_url: OPENAI_PRICING_URL,
533        source_status: SourceStatus::Official,
534        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
535    },
536    ModelCapabilities {
537        provider: "openai",
538        model_id: "gpt-4.1-nano",
539        context_window: Some(1_000_000),
540        max_output_tokens: Some(16_384),
541        pricing: Some(Pricing::flat(0.05, 0.20)),
542        supports_thinking: false,
543        supports_adaptive_thinking: false,
544        source_url: OPENAI_PRICING_URL,
545        source_status: SourceStatus::Official,
546        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
547    },
548    ModelCapabilities {
549        provider: "openai",
550        model_id: "gpt-4o",
551        context_window: Some(128_000),
552        max_output_tokens: Some(16_384),
553        pricing: Some(Pricing::flat(1.25, 5.0)),
554        supports_thinking: false,
555        supports_adaptive_thinking: false,
556        source_url: OPENAI_PRICING_URL,
557        source_status: SourceStatus::Official,
558        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
559    },
560    ModelCapabilities {
561        provider: "openai",
562        model_id: "gpt-4o-mini",
563        context_window: Some(128_000),
564        max_output_tokens: Some(16_384),
565        pricing: Some(Pricing::flat(0.075, 0.30)),
566        supports_thinking: false,
567        supports_adaptive_thinking: false,
568        source_url: OPENAI_PRICING_URL,
569        source_status: SourceStatus::Official,
570        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
571    },
572    // Gemini
573    ModelCapabilities {
574        provider: "gemini",
575        model_id: "gemini-3.1-pro-preview",
576        context_window: Some(1_048_576),
577        max_output_tokens: Some(65_536),
578        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.")),
579        supports_thinking: true,
580        supports_adaptive_thinking: false,
581        source_url: GOOGLE_PRICING_URL,
582        source_status: SourceStatus::Official,
583        notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
584    },
585    ModelCapabilities {
586        provider: "gemini",
587        model_id: "gemini-3.1-pro",
588        context_window: Some(1_048_576),
589        max_output_tokens: Some(65_536),
590        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.")),
591        supports_thinking: true,
592        supports_adaptive_thinking: false,
593        source_url: GOOGLE_PRICING_URL,
594        source_status: SourceStatus::Derived,
595        notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
596    },
597    ModelCapabilities {
598        provider: "gemini",
599        model_id: "gemini-3.1-flash-lite-preview",
600        context_window: Some(1_048_576),
601        max_output_tokens: Some(65_536),
602        pricing: None,
603        supports_thinking: true,
604        supports_adaptive_thinking: false,
605        source_url: GOOGLE_MODELS_URL,
606        source_status: SourceStatus::Unverified,
607        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
608    },
609    ModelCapabilities {
610        provider: "gemini",
611        model_id: "gemini-3-flash-preview",
612        context_window: Some(1_048_576),
613        max_output_tokens: Some(65_536),
614        pricing: None,
615        supports_thinking: true,
616        supports_adaptive_thinking: false,
617        source_url: GOOGLE_MODELS_URL,
618        source_status: SourceStatus::Unverified,
619        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
620    },
621    ModelCapabilities {
622        provider: "gemini",
623        model_id: "gemini-3.0-flash",
624        context_window: Some(1_048_576),
625        max_output_tokens: Some(65_536),
626        pricing: None,
627        supports_thinking: true,
628        supports_adaptive_thinking: false,
629        source_url: GOOGLE_MODELS_URL,
630        source_status: SourceStatus::Derived,
631        notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
632    },
633    ModelCapabilities {
634        provider: "gemini",
635        model_id: "gemini-3.0-pro",
636        context_window: Some(1_048_576),
637        max_output_tokens: Some(65_536),
638        pricing: None,
639        supports_thinking: true,
640        supports_adaptive_thinking: false,
641        source_url: GOOGLE_MODELS_URL,
642        source_status: SourceStatus::Unverified,
643        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
644    },
645    ModelCapabilities {
646        provider: "gemini",
647        model_id: "gemini-2.5-flash",
648        context_window: Some(1_000_000),
649        max_output_tokens: Some(65_536),
650        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.")),
651        supports_thinking: true,
652        supports_adaptive_thinking: false,
653        source_url: GOOGLE_PRICING_URL,
654        source_status: SourceStatus::Official,
655        notes: Some("Official docs state output pricing includes thinking tokens."),
656    },
657    ModelCapabilities {
658        provider: "gemini",
659        model_id: "gemini-2.5-pro",
660        context_window: Some(1_000_000),
661        max_output_tokens: Some(65_536),
662        pricing: None,
663        supports_thinking: true,
664        supports_adaptive_thinking: false,
665        source_url: GOOGLE_MODELS_URL,
666        source_status: SourceStatus::Unverified,
667        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
668    },
669    ModelCapabilities {
670        provider: "gemini",
671        model_id: "gemini-2.0-flash",
672        context_window: Some(1_000_000),
673        max_output_tokens: Some(8_192),
674        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.")),
675        supports_thinking: false,
676        supports_adaptive_thinking: false,
677        source_url: GOOGLE_PRICING_URL,
678        source_status: SourceStatus::Official,
679        notes: None,
680    },
681    ModelCapabilities {
682        provider: "gemini",
683        model_id: "gemini-2.0-flash-lite",
684        context_window: Some(1_000_000),
685        max_output_tokens: Some(8_192),
686        pricing: Some(Pricing::flat(0.075, 0.30)),
687        supports_thinking: false,
688        supports_adaptive_thinking: false,
689        source_url: GOOGLE_PRICING_URL,
690        source_status: SourceStatus::Official,
691        notes: None,
692    },
693    // Open models (z.ai / Moonshot / DeepSeek / MiniMax). All routed through
694    // OpenAIProvider, so provider == "openai" and the model_id is the exact
695    // string the caller passes (OpenRouter slug or native model id).
696    ModelCapabilities {
697        provider: "openai",
698        model_id: "z-ai/glm-5.1",
699        context_window: Some(202_752),
700        max_output_tokens: Some(131_072),
701        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.")),
702        supports_thinking: true,
703        supports_adaptive_thinking: false,
704        source_url: OPENROUTER_GLM51_URL,
705        source_status: SourceStatus::Derived,
706        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."),
707    },
708    ModelCapabilities {
709        provider: "openai",
710        model_id: "glm-5",
711        context_window: Some(200_000),
712        max_output_tokens: Some(131_072),
713        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).")),
714        supports_thinking: true,
715        supports_adaptive_thinking: false,
716        source_url: ZAI_GLM5_PRICING_URL,
717        source_status: SourceStatus::Derived,
718        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."),
719    },
720    ModelCapabilities {
721        provider: "openai",
722        model_id: "moonshotai/kimi-k2.6",
723        context_window: Some(262_144),
724        max_output_tokens: Some(65_536),
725        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.")),
726        supports_thinking: false,
727        supports_adaptive_thinking: false,
728        source_url: OPENROUTER_KIMI_K26_URL,
729        source_status: SourceStatus::Derived,
730        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."),
731    },
732    ModelCapabilities {
733        provider: "openai",
734        model_id: "moonshotai/kimi-k2.5",
735        context_window: Some(262_144),
736        max_output_tokens: Some(32_768),
737        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.")),
738        supports_thinking: false,
739        supports_adaptive_thinking: false,
740        source_url: OPENROUTER_KIMI_K25_URL,
741        source_status: SourceStatus::Derived,
742        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."),
743    },
744    ModelCapabilities {
745        provider: "openai",
746        model_id: "kimi-k2.5",
747        context_window: Some(262_144),
748        max_output_tokens: Some(32_768),
749        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.")),
750        supports_thinking: false,
751        supports_adaptive_thinking: false,
752        source_url: KIMI_K25_AA_URL,
753        source_status: SourceStatus::Unverified,
754        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."),
755    },
756    ModelCapabilities {
757        provider: "openai",
758        model_id: "kimi-k2-thinking",
759        context_window: Some(262_144),
760        max_output_tokens: Some(131_072),
761        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.")),
762        supports_thinking: true,
763        supports_adaptive_thinking: false,
764        source_url: OPENROUTER_KIMI_K2_THINKING_URL,
765        source_status: SourceStatus::Unverified,
766        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."),
767    },
768    ModelCapabilities {
769        provider: "openai",
770        model_id: "deepseek/deepseek-v4-pro",
771        context_window: Some(1_048_576),
772        max_output_tokens: Some(384_000),
773        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.")),
774        supports_thinking: true,
775        supports_adaptive_thinking: false,
776        source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
777        source_status: SourceStatus::Derived,
778        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."),
779    },
780    ModelCapabilities {
781        provider: "openai",
782        model_id: "deepseek-v4-pro",
783        context_window: Some(1_048_576),
784        max_output_tokens: Some(384_000),
785        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.")),
786        supports_thinking: true,
787        supports_adaptive_thinking: false,
788        source_url: DEEPSEEK_PRICING_URL,
789        source_status: SourceStatus::Derived,
790        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."),
791    },
792    ModelCapabilities {
793        provider: "openai",
794        model_id: "deepseek/deepseek-v4-flash",
795        context_window: Some(1_048_576),
796        max_output_tokens: Some(384_000),
797        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.")),
798        supports_thinking: true,
799        supports_adaptive_thinking: false,
800        source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
801        source_status: SourceStatus::Derived,
802        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."),
803    },
804    ModelCapabilities {
805        provider: "openai",
806        model_id: "deepseek-v4-flash",
807        context_window: Some(1_048_576),
808        max_output_tokens: Some(384_000),
809        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.")),
810        supports_thinking: true,
811        supports_adaptive_thinking: false,
812        source_url: DEEPSEEK_PRICING_URL,
813        source_status: SourceStatus::Derived,
814        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."),
815    },
816    ModelCapabilities {
817        provider: "openai",
818        model_id: "MiniMax-M2.5",
819        context_window: Some(204_800),
820        max_output_tokens: Some(131_072),
821        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).")),
822        supports_thinking: true,
823        supports_adaptive_thinking: false,
824        source_url: MINIMAX_PRICING_URL,
825        source_status: SourceStatus::Derived,
826        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."),
827    },
828    ModelCapabilities {
829        provider: "openai",
830        model_id: "minimax/minimax-m2.5",
831        context_window: Some(204_800),
832        max_output_tokens: Some(131_072),
833        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).")),
834        supports_thinking: true,
835        supports_adaptive_thinking: false,
836        source_url: OPENROUTER_MINIMAX_M25_URL,
837        source_status: SourceStatus::Derived,
838        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."),
839    },
840];
841
842#[must_use]
843pub fn get_model_capabilities(
844    provider: &str,
845    model_id: &str,
846) -> Option<&'static ModelCapabilities> {
847    MODEL_CAPABILITIES.iter().find(|caps| {
848        caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
849    })
850}
851
852#[must_use]
853pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
854    get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
855}
856
857#[must_use]
858pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
859    MODEL_CAPABILITIES
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    #[test]
867    fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
868        use anyhow::Context;
869
870        let caps = get_model_capabilities("anthropic", "claude-fable-5")
871            .context("claude-fable-5 capabilities missing")?;
872        assert_eq!(caps.context_window, Some(1_000_000));
873        assert_eq!(caps.max_output_tokens, Some(128_000));
874        assert!(caps.supports_thinking);
875        assert!(caps.supports_adaptive_thinking);
876        assert_eq!(caps.source_status, SourceStatus::Official);
877        let pricing = caps.pricing.context("pricing missing")?;
878        let input = pricing.input.context("input price missing")?;
879        let output = pricing.output.context("output price missing")?;
880        assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
881        assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
882        Ok(())
883    }
884
885    #[test]
886    fn test_lookup_anthropic_opus_48() {
887        let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
888        assert_eq!(caps.context_window, Some(1_000_000));
889        assert_eq!(caps.max_output_tokens, Some(128_000));
890        assert!(caps.supports_thinking);
891        assert!(caps.supports_adaptive_thinking);
892    }
893
894    #[test]
895    fn test_lookup_anthropic_opus_46() {
896        let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
897        assert_eq!(caps.context_window, Some(1_000_000));
898        assert_eq!(caps.max_output_tokens, Some(128_000));
899        assert!(caps.supports_adaptive_thinking);
900    }
901
902    #[test]
903    fn test_lookup_anthropic_sonnet_5() {
904        let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
905        assert_eq!(caps.context_window, Some(1_000_000));
906        assert_eq!(caps.max_output_tokens, Some(128_000));
907        assert!(caps.supports_thinking);
908        // Adaptive-only (like Opus 4.8): manual budget_tokens 400s; adaptive is required.
909        assert!(caps.supports_adaptive_thinking);
910    }
911
912    #[test]
913    fn test_lookup_anthropic_sonnet_46() {
914        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
915        assert_eq!(caps.context_window, Some(1_000_000));
916        assert_eq!(caps.max_output_tokens, Some(64_000));
917        assert!(caps.supports_adaptive_thinking);
918    }
919
920    #[test]
921    fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
922        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
923        assert!(!caps.supports_adaptive_thinking);
924    }
925
926    #[test]
927    fn test_lookup_openai_pricing() {
928        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
929        let pricing = caps.pricing.unwrap();
930        assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
931        assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
932    }
933
934    #[test]
935    fn test_lookup_openai_gpt54() {
936        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
937        assert_eq!(caps.context_window, Some(1_050_000));
938        assert_eq!(caps.max_output_tokens, Some(128_000));
939        assert!(caps.supports_thinking);
940        assert_eq!(caps.source_status, SourceStatus::Official);
941    }
942
943    #[test]
944    fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
945        use anyhow::Context;
946
947        let caps = get_model_capabilities("openai", "gpt-5.2-pro")
948            .context("gpt-5.2-pro capabilities missing")?;
949        assert_eq!(caps.context_window, Some(400_000));
950        assert_eq!(caps.max_output_tokens, Some(128_000));
951        assert!(caps.supports_thinking);
952        assert_eq!(caps.source_status, SourceStatus::Official);
953        let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
954        let input = pricing.input.context("input price missing")?;
955        let output = pricing.output.context("output price missing")?;
956        assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
957        assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
958        Ok(())
959    }
960
961    #[test]
962    fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
963        use anyhow::Context as _;
964
965        for (model_id, input, cached_input, output, cache_write_note) in [
966            ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
967            ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
968            ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
969            ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
970        ] {
971            let caps = get_model_capabilities("openai", model_id)
972                .with_context(|| format!("{model_id} capabilities missing"))?;
973            assert_eq!(caps.context_window, Some(1_050_000));
974            assert_eq!(caps.max_output_tokens, Some(128_000));
975            assert!(caps.supports_thinking);
976            assert!(caps.supports_adaptive_thinking);
977            assert_eq!(caps.source_status, SourceStatus::Official);
978            let pricing = caps
979                .pricing
980                .with_context(|| format!("{model_id} pricing missing"))?;
981            assert_eq!(pricing.input, Some(PricePoint::new(input)));
982            assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
983            assert_eq!(pricing.output, Some(PricePoint::new(output)));
984            assert!(pricing.notes.is_some_and(|notes| {
985                notes.contains(cache_write_note) && notes.contains("more than 272K")
986            }));
987        }
988
989        Ok(())
990    }
991
992    #[test]
993    fn test_lookup_openai_gpt53_codex() {
994        let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
995        assert_eq!(caps.context_window, Some(400_000));
996        assert_eq!(caps.max_output_tokens, Some(128_000));
997        assert!(caps.supports_adaptive_thinking);
998        assert!(caps.supports_thinking);
999        assert_eq!(caps.source_status, SourceStatus::Official);
1000    }
1001
1002    #[test]
1003    fn test_lookup_gemini_preview_models() {
1004        let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1005        assert_eq!(flash.context_window, Some(1_048_576));
1006        assert!(flash.supports_thinking);
1007
1008        let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1009        assert_eq!(pro.max_output_tokens, Some(65_536));
1010        assert!(pro.supports_thinking);
1011    }
1012
1013    #[test]
1014    fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1015        // DeepSeek V4 Pro via OpenRouter slug — reasoning model.
1016        let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1017        assert!(deepseek.supports_thinking);
1018        assert_eq!(deepseek.max_output_tokens, Some(384_000));
1019        let pricing = deepseek.pricing.unwrap();
1020        assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1021        assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1022
1023        // z.ai GLM-5.1 via OpenRouter slug — reasoning model.
1024        let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1025        assert!(glm.supports_thinking);
1026        assert_eq!(glm.max_output_tokens, Some(131_072));
1027        let glm_pricing = glm.pricing.unwrap();
1028        assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1029        assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1030
1031        // Kimi K2 Thinking native — reasoning model.
1032        let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1033        assert!(kimi_thinking.supports_thinking);
1034        assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1035        assert!(
1036            kimi_thinking
1037                .pricing
1038                .unwrap()
1039                .output
1040                .unwrap()
1041                .usd_per_million_tokens
1042                > 0.0
1043        );
1044    }
1045
1046    #[test]
1047    fn test_lookup_open_non_reasoning_kimi_models() {
1048        // Kimi K2.6 / K2.5 are registered as non-reasoning coding models.
1049        let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1050        assert!(!k26.supports_thinking);
1051        assert_eq!(k26.max_output_tokens, Some(65_536));
1052        assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1053
1054        let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1055        assert!(!k25_native.supports_thinking);
1056        assert_eq!(k25_native.max_output_tokens, Some(32_768));
1057    }
1058
1059    #[test]
1060    fn test_lookup_all_open_models_resolve() {
1061        // Every model_id below is exactly how the consumer looks them up
1062        // (provider == "openai" for all open routes).
1063        for model_id in [
1064            "z-ai/glm-5.1",
1065            "glm-5",
1066            "moonshotai/kimi-k2.6",
1067            "moonshotai/kimi-k2.5",
1068            "kimi-k2.5",
1069            "kimi-k2-thinking",
1070            "deepseek/deepseek-v4-pro",
1071            "deepseek-v4-pro",
1072            "deepseek/deepseek-v4-flash",
1073            "deepseek-v4-flash",
1074            "MiniMax-M2.5",
1075            "minimax/minimax-m2.5",
1076        ] {
1077            let caps = get_model_capabilities("openai", model_id)
1078                .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1079            assert!(
1080                caps.pricing.is_some(),
1081                "pricing should be populated for {model_id}"
1082            );
1083            assert!(
1084                caps.max_output_tokens.is_some_and(|m| m > 0),
1085                "max_output_tokens should be non-zero for {model_id}"
1086            );
1087            assert!(
1088                caps.context_window.is_some_and(|c| c > 0),
1089                "context_window should be non-zero for {model_id}"
1090            );
1091        }
1092    }
1093
1094    #[test]
1095    fn test_lookup_minimax_native_pricing() {
1096        let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1097        assert!(native.supports_thinking);
1098        let pricing = native.pricing.unwrap();
1099        assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1100        assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1101        // Cache-read is the first-party platform.minimax.io PAYG rate ($0.03/M),
1102        // not the ~$0.155/M that an earlier entry overstated by ~3-5x.
1103        assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1104    }
1105
1106    #[test]
1107    fn test_estimate_cost_usd() {
1108        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1109        let cost = caps
1110            .estimate_cost_usd(&Usage {
1111                input_tokens: 2_000,
1112                output_tokens: 1_000,
1113                cached_input_tokens: 0,
1114                cache_creation_input_tokens: 0,
1115            })
1116            .unwrap();
1117        assert!((cost - 0.0075).abs() < f64::EPSILON);
1118    }
1119
1120    #[test]
1121    fn test_estimate_cost_usd_with_cached_input() {
1122        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1123        let cost = caps
1124            .estimate_cost_usd(&Usage {
1125                input_tokens: 2_000,
1126                output_tokens: 1_000,
1127                cached_input_tokens: 1_000,
1128                cache_creation_input_tokens: 0,
1129            })
1130            .unwrap();
1131        assert!((cost - 0.01775).abs() < f64::EPSILON);
1132    }
1133}