Skip to main content

agent_sdk/
model_capabilities.rs

1use crate::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_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
119const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
120const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
121const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
122
123const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
124    // Anthropic
125    ModelCapabilities {
126        provider: "anthropic",
127        model_id: "claude-opus-4-6",
128        context_window: Some(200_000),
129        max_output_tokens: Some(128_000),
130        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.")),
131        supports_thinking: true,
132        supports_adaptive_thinking: true,
133        source_url: ANTHROPIC_MODELS_URL,
134        source_status: SourceStatus::Derived,
135        notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
136    },
137    ModelCapabilities {
138        provider: "anthropic",
139        model_id: "claude-sonnet-4-6",
140        context_window: Some(200_000),
141        max_output_tokens: Some(64_000),
142        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
143        supports_thinking: true,
144        supports_adaptive_thinking: true,
145        source_url: ANTHROPIC_MODELS_URL,
146        source_status: SourceStatus::Derived,
147        notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
148    },
149    ModelCapabilities {
150        provider: "anthropic",
151        model_id: "claude-sonnet-4-5-20250929",
152        context_window: Some(200_000),
153        max_output_tokens: Some(64_000),
154        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
155        supports_thinking: true,
156        supports_adaptive_thinking: false,
157        source_url: ANTHROPIC_MODELS_URL,
158        source_status: SourceStatus::Derived,
159        notes: None,
160    },
161    ModelCapabilities {
162        provider: "anthropic",
163        model_id: "claude-haiku-4-5-20251001",
164        context_window: Some(200_000),
165        max_output_tokens: Some(64_000),
166        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
167        supports_thinking: true,
168        supports_adaptive_thinking: false,
169        source_url: ANTHROPIC_MODELS_URL,
170        source_status: SourceStatus::Derived,
171        notes: None,
172    },
173    ModelCapabilities {
174        provider: "anthropic",
175        model_id: "claude-sonnet-4-20250514",
176        context_window: Some(200_000),
177        max_output_tokens: Some(64_000),
178        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
179        supports_thinking: true,
180        supports_adaptive_thinking: false,
181        source_url: ANTHROPIC_MODELS_URL,
182        source_status: SourceStatus::Derived,
183        notes: None,
184    },
185    ModelCapabilities {
186        provider: "anthropic",
187        model_id: "claude-opus-4-20250514",
188        context_window: Some(200_000),
189        max_output_tokens: Some(32_000),
190        pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
191        supports_thinking: true,
192        supports_adaptive_thinking: false,
193        source_url: ANTHROPIC_MODELS_URL,
194        source_status: SourceStatus::Derived,
195        notes: None,
196    },
197    ModelCapabilities {
198        provider: "anthropic",
199        model_id: "claude-3-5-sonnet-20241022",
200        context_window: Some(200_000),
201        max_output_tokens: Some(8_192),
202        pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
203        supports_thinking: true,
204        supports_adaptive_thinking: false,
205        source_url: ANTHROPIC_MODELS_URL,
206        source_status: SourceStatus::Derived,
207        notes: None,
208    },
209    ModelCapabilities {
210        provider: "anthropic",
211        model_id: "claude-3-5-haiku-20241022",
212        context_window: Some(200_000),
213        max_output_tokens: Some(8_192),
214        pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
215        supports_thinking: true,
216        supports_adaptive_thinking: false,
217        source_url: ANTHROPIC_MODELS_URL,
218        source_status: SourceStatus::Derived,
219        notes: None,
220    },
221    // OpenAI
222    ModelCapabilities {
223        provider: "openai",
224        model_id: "gpt-5.4",
225        context_window: Some(1_050_000),
226        max_output_tokens: Some(128_000),
227        pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
228        supports_thinking: true,
229        supports_adaptive_thinking: false,
230        source_url: OPENAI_GPT54_URL,
231        source_status: SourceStatus::Official,
232        notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
233    },
234    ModelCapabilities {
235        provider: "openai",
236        model_id: "gpt-5.3-codex",
237        context_window: Some(400_000),
238        max_output_tokens: Some(120_000),
239        pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
240        supports_thinking: true,
241        supports_adaptive_thinking: false,
242        source_url: OPENAI_GPT53_CODEX_URL,
243        source_status: SourceStatus::Official,
244        notes: Some("OpenAI model docs list Chat Completions and Responses API support plus reasoning.effort levels."),
245    },
246    ModelCapabilities {
247        provider: "openai",
248        model_id: "gpt-5",
249        context_window: Some(400_000),
250        max_output_tokens: Some(128_000),
251        pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
252        supports_thinking: false,
253        supports_adaptive_thinking: false,
254        source_url: OPENAI_PRICING_URL,
255        source_status: SourceStatus::Official,
256        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
257    },
258    ModelCapabilities {
259        provider: "openai",
260        model_id: "gpt-5-mini",
261        context_window: Some(400_000),
262        max_output_tokens: Some(128_000),
263        pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
264        supports_thinking: false,
265        supports_adaptive_thinking: false,
266        source_url: OPENAI_PRICING_URL,
267        source_status: SourceStatus::Official,
268        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
269    },
270    ModelCapabilities {
271        provider: "openai",
272        model_id: "gpt-5-nano",
273        context_window: Some(400_000),
274        max_output_tokens: Some(128_000),
275        pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
276        supports_thinking: false,
277        supports_adaptive_thinking: false,
278        source_url: OPENAI_PRICING_URL,
279        source_status: SourceStatus::Official,
280        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
281    },
282    ModelCapabilities {
283        provider: "openai",
284        model_id: "gpt-5.2-instant",
285        context_window: Some(400_000),
286        max_output_tokens: Some(128_000),
287        pricing: None,
288        supports_thinking: false,
289        supports_adaptive_thinking: false,
290        source_url: OPENAI_MODELS_URL,
291        source_status: SourceStatus::Unverified,
292        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
293    },
294    ModelCapabilities {
295        provider: "openai",
296        model_id: "gpt-5.2-thinking",
297        context_window: Some(400_000),
298        max_output_tokens: Some(128_000),
299        pricing: None,
300        supports_thinking: true,
301        supports_adaptive_thinking: false,
302        source_url: OPENAI_MODELS_URL,
303        source_status: SourceStatus::Unverified,
304        notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
305    },
306    ModelCapabilities {
307        provider: "openai",
308        model_id: "gpt-5.2-pro",
309        context_window: Some(400_000),
310        max_output_tokens: Some(128_000),
311        pricing: Some(Pricing::flat(10.50, 84.0)),
312        supports_thinking: false,
313        supports_adaptive_thinking: false,
314        source_url: OPENAI_PRICING_URL,
315        source_status: SourceStatus::Official,
316        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
317    },
318    ModelCapabilities {
319        provider: "openai",
320        model_id: "gpt-5.2-codex",
321        context_window: Some(400_000),
322        max_output_tokens: Some(128_000),
323        pricing: None,
324        supports_thinking: false,
325        supports_adaptive_thinking: false,
326        source_url: OPENAI_MODELS_URL,
327        source_status: SourceStatus::Unverified,
328        notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
329    },
330    ModelCapabilities {
331        provider: "openai",
332        model_id: "o3",
333        context_window: Some(200_000),
334        max_output_tokens: Some(100_000),
335        pricing: Some(Pricing::flat(1.0, 4.0)),
336        supports_thinking: true,
337        supports_adaptive_thinking: false,
338        source_url: OPENAI_PRICING_URL,
339        source_status: SourceStatus::Official,
340        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
341    },
342    ModelCapabilities {
343        provider: "openai",
344        model_id: "o3-mini",
345        context_window: Some(200_000),
346        max_output_tokens: Some(100_000),
347        pricing: Some(Pricing::flat(0.55, 2.20)),
348        supports_thinking: true,
349        supports_adaptive_thinking: false,
350        source_url: OPENAI_PRICING_URL,
351        source_status: SourceStatus::Official,
352        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
353    },
354    ModelCapabilities {
355        provider: "openai",
356        model_id: "o4-mini",
357        context_window: Some(200_000),
358        max_output_tokens: Some(100_000),
359        pricing: Some(Pricing::flat(0.55, 2.20)),
360        supports_thinking: true,
361        supports_adaptive_thinking: false,
362        source_url: OPENAI_PRICING_URL,
363        source_status: SourceStatus::Official,
364        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
365    },
366    ModelCapabilities {
367        provider: "openai",
368        model_id: "o1",
369        context_window: Some(200_000),
370        max_output_tokens: Some(100_000),
371        pricing: Some(Pricing::flat(7.50, 30.0)),
372        supports_thinking: true,
373        supports_adaptive_thinking: false,
374        source_url: OPENAI_PRICING_URL,
375        source_status: SourceStatus::Official,
376        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
377    },
378    ModelCapabilities {
379        provider: "openai",
380        model_id: "o1-mini",
381        context_window: Some(200_000),
382        max_output_tokens: Some(100_000),
383        pricing: Some(Pricing::flat(0.55, 2.20)),
384        supports_thinking: true,
385        supports_adaptive_thinking: false,
386        source_url: OPENAI_PRICING_URL,
387        source_status: SourceStatus::Official,
388        notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
389    },
390    ModelCapabilities {
391        provider: "openai",
392        model_id: "gpt-4.1",
393        context_window: Some(1_000_000),
394        max_output_tokens: Some(16_384),
395        pricing: Some(Pricing::flat(1.0, 4.0)),
396        supports_thinking: false,
397        supports_adaptive_thinking: false,
398        source_url: OPENAI_PRICING_URL,
399        source_status: SourceStatus::Official,
400        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
401    },
402    ModelCapabilities {
403        provider: "openai",
404        model_id: "gpt-4.1-mini",
405        context_window: Some(1_000_000),
406        max_output_tokens: Some(16_384),
407        pricing: Some(Pricing::flat(0.20, 0.80)),
408        supports_thinking: false,
409        supports_adaptive_thinking: false,
410        source_url: OPENAI_PRICING_URL,
411        source_status: SourceStatus::Official,
412        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
413    },
414    ModelCapabilities {
415        provider: "openai",
416        model_id: "gpt-4.1-nano",
417        context_window: Some(1_000_000),
418        max_output_tokens: Some(16_384),
419        pricing: Some(Pricing::flat(0.05, 0.20)),
420        supports_thinking: false,
421        supports_adaptive_thinking: false,
422        source_url: OPENAI_PRICING_URL,
423        source_status: SourceStatus::Official,
424        notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
425    },
426    ModelCapabilities {
427        provider: "openai",
428        model_id: "gpt-4o",
429        context_window: Some(128_000),
430        max_output_tokens: Some(16_384),
431        pricing: Some(Pricing::flat(1.25, 5.0)),
432        supports_thinking: false,
433        supports_adaptive_thinking: false,
434        source_url: OPENAI_PRICING_URL,
435        source_status: SourceStatus::Official,
436        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
437    },
438    ModelCapabilities {
439        provider: "openai",
440        model_id: "gpt-4o-mini",
441        context_window: Some(128_000),
442        max_output_tokens: Some(16_384),
443        pricing: Some(Pricing::flat(0.075, 0.30)),
444        supports_thinking: false,
445        supports_adaptive_thinking: false,
446        source_url: OPENAI_PRICING_URL,
447        source_status: SourceStatus::Official,
448        notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
449    },
450    // Gemini
451    ModelCapabilities {
452        provider: "gemini",
453        model_id: "gemini-3.1-pro-preview",
454        context_window: Some(1_048_576),
455        max_output_tokens: Some(65_536),
456        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.")),
457        supports_thinking: true,
458        supports_adaptive_thinking: false,
459        source_url: GOOGLE_PRICING_URL,
460        source_status: SourceStatus::Official,
461        notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
462    },
463    ModelCapabilities {
464        provider: "gemini",
465        model_id: "gemini-3.1-pro",
466        context_window: Some(1_048_576),
467        max_output_tokens: Some(65_536),
468        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.")),
469        supports_thinking: true,
470        supports_adaptive_thinking: false,
471        source_url: GOOGLE_PRICING_URL,
472        source_status: SourceStatus::Derived,
473        notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
474    },
475    ModelCapabilities {
476        provider: "gemini",
477        model_id: "gemini-3.1-flash-lite-preview",
478        context_window: Some(1_048_576),
479        max_output_tokens: Some(65_536),
480        pricing: None,
481        supports_thinking: true,
482        supports_adaptive_thinking: false,
483        source_url: GOOGLE_MODELS_URL,
484        source_status: SourceStatus::Unverified,
485        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
486    },
487    ModelCapabilities {
488        provider: "gemini",
489        model_id: "gemini-3-flash-preview",
490        context_window: Some(1_048_576),
491        max_output_tokens: Some(65_536),
492        pricing: None,
493        supports_thinking: true,
494        supports_adaptive_thinking: false,
495        source_url: GOOGLE_MODELS_URL,
496        source_status: SourceStatus::Unverified,
497        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
498    },
499    ModelCapabilities {
500        provider: "gemini",
501        model_id: "gemini-3.0-flash",
502        context_window: Some(1_048_576),
503        max_output_tokens: Some(65_536),
504        pricing: None,
505        supports_thinking: true,
506        supports_adaptive_thinking: false,
507        source_url: GOOGLE_MODELS_URL,
508        source_status: SourceStatus::Derived,
509        notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
510    },
511    ModelCapabilities {
512        provider: "gemini",
513        model_id: "gemini-3.0-pro",
514        context_window: Some(1_048_576),
515        max_output_tokens: Some(65_536),
516        pricing: None,
517        supports_thinking: true,
518        supports_adaptive_thinking: false,
519        source_url: GOOGLE_MODELS_URL,
520        source_status: SourceStatus::Unverified,
521        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
522    },
523    ModelCapabilities {
524        provider: "gemini",
525        model_id: "gemini-2.5-flash",
526        context_window: Some(1_000_000),
527        max_output_tokens: Some(65_536),
528        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.")),
529        supports_thinking: true,
530        supports_adaptive_thinking: false,
531        source_url: GOOGLE_PRICING_URL,
532        source_status: SourceStatus::Official,
533        notes: Some("Official docs state output pricing includes thinking tokens."),
534    },
535    ModelCapabilities {
536        provider: "gemini",
537        model_id: "gemini-2.5-pro",
538        context_window: Some(1_000_000),
539        max_output_tokens: Some(65_536),
540        pricing: None,
541        supports_thinking: true,
542        supports_adaptive_thinking: false,
543        source_url: GOOGLE_MODELS_URL,
544        source_status: SourceStatus::Unverified,
545        notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
546    },
547    ModelCapabilities {
548        provider: "gemini",
549        model_id: "gemini-2.0-flash",
550        context_window: Some(1_000_000),
551        max_output_tokens: Some(8_192),
552        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.")),
553        supports_thinking: false,
554        supports_adaptive_thinking: false,
555        source_url: GOOGLE_PRICING_URL,
556        source_status: SourceStatus::Official,
557        notes: None,
558    },
559    ModelCapabilities {
560        provider: "gemini",
561        model_id: "gemini-2.0-flash-lite",
562        context_window: Some(1_000_000),
563        max_output_tokens: Some(8_192),
564        pricing: Some(Pricing::flat(0.075, 0.30)),
565        supports_thinking: false,
566        supports_adaptive_thinking: false,
567        source_url: GOOGLE_PRICING_URL,
568        source_status: SourceStatus::Official,
569        notes: None,
570    },
571];
572
573#[must_use]
574pub fn get_model_capabilities(
575    provider: &str,
576    model_id: &str,
577) -> Option<&'static ModelCapabilities> {
578    MODEL_CAPABILITIES.iter().find(|caps| {
579        caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
580    })
581}
582
583#[must_use]
584pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
585    get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
586}
587
588#[must_use]
589pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
590    MODEL_CAPABILITIES
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn test_lookup_anthropic_sonnet_46() {
599        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
600        assert_eq!(caps.context_window, Some(200_000));
601        assert_eq!(caps.max_output_tokens, Some(64_000));
602        assert!(caps.supports_adaptive_thinking);
603    }
604
605    #[test]
606    fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
607        let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
608        assert!(!caps.supports_adaptive_thinking);
609    }
610
611    #[test]
612    fn test_lookup_openai_pricing() {
613        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
614        let pricing = caps.pricing.unwrap();
615        assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
616        assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
617    }
618
619    #[test]
620    fn test_lookup_openai_gpt54() {
621        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
622        assert_eq!(caps.context_window, Some(1_050_000));
623        assert_eq!(caps.max_output_tokens, Some(128_000));
624        assert!(caps.supports_thinking);
625        assert_eq!(caps.source_status, SourceStatus::Official);
626    }
627
628    #[test]
629    fn test_lookup_openai_gpt53_codex() {
630        let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
631        assert_eq!(caps.context_window, Some(400_000));
632        assert_eq!(caps.max_output_tokens, Some(120_000));
633        assert!(caps.supports_thinking);
634        assert_eq!(caps.source_status, SourceStatus::Official);
635    }
636
637    #[test]
638    fn test_lookup_gemini_preview_models() {
639        let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
640        assert_eq!(flash.context_window, Some(1_048_576));
641        assert!(flash.supports_thinking);
642
643        let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
644        assert_eq!(pro.max_output_tokens, Some(65_536));
645        assert!(pro.supports_thinking);
646    }
647
648    #[test]
649    fn test_estimate_cost_usd() {
650        let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
651        let cost = caps
652            .estimate_cost_usd(&Usage {
653                input_tokens: 2_000,
654                output_tokens: 1_000,
655                cached_input_tokens: 0,
656            })
657            .unwrap();
658        assert!((cost - 0.0075).abs() < f64::EPSILON);
659    }
660
661    #[test]
662    fn test_estimate_cost_usd_with_cached_input() {
663        let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
664        let cost = caps
665            .estimate_cost_usd(&Usage {
666                input_tokens: 2_000,
667                output_tokens: 1_000,
668                cached_input_tokens: 1_000,
669            })
670            .unwrap();
671        assert!((cost - 0.01775).abs() < f64::EPSILON);
672    }
673}