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 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 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 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 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}