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 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-8",
128 context_window: Some(1_000_000),
129 max_output_tokens: Some(128_000),
130 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.")),
131 supports_thinking: true,
132 supports_adaptive_thinking: true,
133 source_url: ANTHROPIC_MODELS_URL,
134 source_status: SourceStatus::Derived,
135 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."),
136 },
137 ModelCapabilities {
138 provider: "anthropic",
139 model_id: "claude-opus-4-7",
140 context_window: Some(1_000_000),
141 max_output_tokens: Some(128_000),
142 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.")),
143 supports_thinking: true,
144 supports_adaptive_thinking: true,
145 source_url: ANTHROPIC_MODELS_URL,
146 source_status: SourceStatus::Derived,
147 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."),
148 },
149 ModelCapabilities {
150 provider: "anthropic",
151 model_id: "claude-opus-4-6",
152 context_window: Some(1_000_000),
153 max_output_tokens: Some(128_000),
154 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.")),
155 supports_thinking: true,
156 supports_adaptive_thinking: true,
157 source_url: ANTHROPIC_MODELS_URL,
158 source_status: SourceStatus::Derived,
159 notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
160 },
161 ModelCapabilities {
162 provider: "anthropic",
163 model_id: "claude-sonnet-4-6",
164 context_window: Some(1_000_000),
165 max_output_tokens: Some(64_000),
166 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
167 supports_thinking: true,
168 supports_adaptive_thinking: true,
169 source_url: ANTHROPIC_MODELS_URL,
170 source_status: SourceStatus::Derived,
171 notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
172 },
173 ModelCapabilities {
174 provider: "anthropic",
175 model_id: "claude-sonnet-4-5-20250929",
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-haiku-4-5-20251001",
188 context_window: Some(200_000),
189 max_output_tokens: Some(64_000),
190 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku 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-sonnet-4-20250514",
200 context_window: Some(200_000),
201 max_output_tokens: Some(64_000),
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-opus-4-20250514",
212 context_window: Some(200_000),
213 max_output_tokens: Some(32_000),
214 pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus 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 {
222 provider: "anthropic",
223 model_id: "claude-3-5-sonnet-20241022",
224 context_window: Some(200_000),
225 max_output_tokens: Some(8_192),
226 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
227 supports_thinking: true,
228 supports_adaptive_thinking: false,
229 source_url: ANTHROPIC_MODELS_URL,
230 source_status: SourceStatus::Derived,
231 notes: None,
232 },
233 ModelCapabilities {
234 provider: "anthropic",
235 model_id: "claude-3-5-haiku-20241022",
236 context_window: Some(200_000),
237 max_output_tokens: Some(8_192),
238 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
239 supports_thinking: true,
240 supports_adaptive_thinking: false,
241 source_url: ANTHROPIC_MODELS_URL,
242 source_status: SourceStatus::Derived,
243 notes: None,
244 },
245 ModelCapabilities {
247 provider: "openai",
248 model_id: "gpt-5.4",
249 context_window: Some(1_050_000),
250 max_output_tokens: Some(128_000),
251 pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
252 supports_thinking: true,
253 supports_adaptive_thinking: false,
254 source_url: OPENAI_GPT54_URL,
255 source_status: SourceStatus::Official,
256 notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
257 },
258 ModelCapabilities {
259 provider: "openai",
260 model_id: "gpt-5.3-codex",
261 context_window: Some(400_000),
262 max_output_tokens: Some(120_000),
263 pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
264 supports_thinking: true,
265 supports_adaptive_thinking: false,
266 source_url: OPENAI_GPT53_CODEX_URL,
267 source_status: SourceStatus::Official,
268 notes: Some("OpenAI model docs list Chat Completions and Responses API support plus reasoning.effort levels."),
269 },
270 ModelCapabilities {
271 provider: "openai",
272 model_id: "gpt-5",
273 context_window: Some(400_000),
274 max_output_tokens: Some(128_000),
275 pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
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-mini",
285 context_window: Some(400_000),
286 max_output_tokens: Some(128_000),
287 pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
288 supports_thinking: false,
289 supports_adaptive_thinking: false,
290 source_url: OPENAI_PRICING_URL,
291 source_status: SourceStatus::Official,
292 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
293 },
294 ModelCapabilities {
295 provider: "openai",
296 model_id: "gpt-5-nano",
297 context_window: Some(400_000),
298 max_output_tokens: Some(128_000),
299 pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
300 supports_thinking: false,
301 supports_adaptive_thinking: false,
302 source_url: OPENAI_PRICING_URL,
303 source_status: SourceStatus::Official,
304 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
305 },
306 ModelCapabilities {
307 provider: "openai",
308 model_id: "gpt-5.2-instant",
309 context_window: Some(400_000),
310 max_output_tokens: Some(128_000),
311 pricing: None,
312 supports_thinking: false,
313 supports_adaptive_thinking: false,
314 source_url: OPENAI_MODELS_URL,
315 source_status: SourceStatus::Unverified,
316 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
317 },
318 ModelCapabilities {
319 provider: "openai",
320 model_id: "gpt-5.2-thinking",
321 context_window: Some(400_000),
322 max_output_tokens: Some(128_000),
323 pricing: None,
324 supports_thinking: true,
325 supports_adaptive_thinking: false,
326 source_url: OPENAI_MODELS_URL,
327 source_status: SourceStatus::Unverified,
328 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
329 },
330 ModelCapabilities {
331 provider: "openai",
332 model_id: "gpt-5.2-pro",
333 context_window: Some(400_000),
334 max_output_tokens: Some(128_000),
335 pricing: Some(Pricing::flat(10.50, 84.0)),
336 supports_thinking: false,
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: "gpt-5.2-codex",
345 context_window: Some(400_000),
346 max_output_tokens: Some(128_000),
347 pricing: None,
348 supports_thinking: false,
349 supports_adaptive_thinking: false,
350 source_url: OPENAI_MODELS_URL,
351 source_status: SourceStatus::Unverified,
352 notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
353 },
354 ModelCapabilities {
355 provider: "openai",
356 model_id: "o3",
357 context_window: Some(200_000),
358 max_output_tokens: Some(100_000),
359 pricing: Some(Pricing::flat(1.0, 4.0)),
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: "o3-mini",
369 context_window: Some(200_000),
370 max_output_tokens: Some(100_000),
371 pricing: Some(Pricing::flat(0.55, 2.20)),
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: "o4-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: "o1",
393 context_window: Some(200_000),
394 max_output_tokens: Some(100_000),
395 pricing: Some(Pricing::flat(7.50, 30.0)),
396 supports_thinking: true,
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/max output still need clean extraction from models docs."),
401 },
402 ModelCapabilities {
403 provider: "openai",
404 model_id: "o1-mini",
405 context_window: Some(200_000),
406 max_output_tokens: Some(100_000),
407 pricing: Some(Pricing::flat(0.55, 2.20)),
408 supports_thinking: true,
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/max output still need clean extraction from models docs."),
413 },
414 ModelCapabilities {
415 provider: "openai",
416 model_id: "gpt-4.1",
417 context_window: Some(1_000_000),
418 max_output_tokens: Some(16_384),
419 pricing: Some(Pricing::flat(1.0, 4.0)),
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-4.1-mini",
429 context_window: Some(1_000_000),
430 max_output_tokens: Some(16_384),
431 pricing: Some(Pricing::flat(0.20, 0.80)),
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 window from model family docs/notes."),
437 },
438 ModelCapabilities {
439 provider: "openai",
440 model_id: "gpt-4.1-nano",
441 context_window: Some(1_000_000),
442 max_output_tokens: Some(16_384),
443 pricing: Some(Pricing::flat(0.05, 0.20)),
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 window from model family docs/notes."),
449 },
450 ModelCapabilities {
451 provider: "openai",
452 model_id: "gpt-4o",
453 context_window: Some(128_000),
454 max_output_tokens: Some(16_384),
455 pricing: Some(Pricing::flat(1.25, 5.0)),
456 supports_thinking: false,
457 supports_adaptive_thinking: false,
458 source_url: OPENAI_PRICING_URL,
459 source_status: SourceStatus::Official,
460 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
461 },
462 ModelCapabilities {
463 provider: "openai",
464 model_id: "gpt-4o-mini",
465 context_window: Some(128_000),
466 max_output_tokens: Some(16_384),
467 pricing: Some(Pricing::flat(0.075, 0.30)),
468 supports_thinking: false,
469 supports_adaptive_thinking: false,
470 source_url: OPENAI_PRICING_URL,
471 source_status: SourceStatus::Official,
472 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
473 },
474 ModelCapabilities {
476 provider: "gemini",
477 model_id: "gemini-3.1-pro-preview",
478 context_window: Some(1_048_576),
479 max_output_tokens: Some(65_536),
480 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.")),
481 supports_thinking: true,
482 supports_adaptive_thinking: false,
483 source_url: GOOGLE_PRICING_URL,
484 source_status: SourceStatus::Official,
485 notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
486 },
487 ModelCapabilities {
488 provider: "gemini",
489 model_id: "gemini-3.1-pro",
490 context_window: Some(1_048_576),
491 max_output_tokens: Some(65_536),
492 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.")),
493 supports_thinking: true,
494 supports_adaptive_thinking: false,
495 source_url: GOOGLE_PRICING_URL,
496 source_status: SourceStatus::Derived,
497 notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
498 },
499 ModelCapabilities {
500 provider: "gemini",
501 model_id: "gemini-3.1-flash-lite-preview",
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::Unverified,
509 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
510 },
511 ModelCapabilities {
512 provider: "gemini",
513 model_id: "gemini-3-flash-preview",
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-3.0-flash",
526 context_window: Some(1_048_576),
527 max_output_tokens: Some(65_536),
528 pricing: None,
529 supports_thinking: true,
530 supports_adaptive_thinking: false,
531 source_url: GOOGLE_MODELS_URL,
532 source_status: SourceStatus::Derived,
533 notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
534 },
535 ModelCapabilities {
536 provider: "gemini",
537 model_id: "gemini-3.0-pro",
538 context_window: Some(1_048_576),
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.5-flash",
550 context_window: Some(1_000_000),
551 max_output_tokens: Some(65_536),
552 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.")),
553 supports_thinking: true,
554 supports_adaptive_thinking: false,
555 source_url: GOOGLE_PRICING_URL,
556 source_status: SourceStatus::Official,
557 notes: Some("Official docs state output pricing includes thinking tokens."),
558 },
559 ModelCapabilities {
560 provider: "gemini",
561 model_id: "gemini-2.5-pro",
562 context_window: Some(1_000_000),
563 max_output_tokens: Some(65_536),
564 pricing: None,
565 supports_thinking: true,
566 supports_adaptive_thinking: false,
567 source_url: GOOGLE_MODELS_URL,
568 source_status: SourceStatus::Unverified,
569 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
570 },
571 ModelCapabilities {
572 provider: "gemini",
573 model_id: "gemini-2.0-flash",
574 context_window: Some(1_000_000),
575 max_output_tokens: Some(8_192),
576 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.")),
577 supports_thinking: false,
578 supports_adaptive_thinking: false,
579 source_url: GOOGLE_PRICING_URL,
580 source_status: SourceStatus::Official,
581 notes: None,
582 },
583 ModelCapabilities {
584 provider: "gemini",
585 model_id: "gemini-2.0-flash-lite",
586 context_window: Some(1_000_000),
587 max_output_tokens: Some(8_192),
588 pricing: Some(Pricing::flat(0.075, 0.30)),
589 supports_thinking: false,
590 supports_adaptive_thinking: false,
591 source_url: GOOGLE_PRICING_URL,
592 source_status: SourceStatus::Official,
593 notes: None,
594 },
595];
596
597#[must_use]
598pub fn get_model_capabilities(
599 provider: &str,
600 model_id: &str,
601) -> Option<&'static ModelCapabilities> {
602 MODEL_CAPABILITIES.iter().find(|caps| {
603 caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
604 })
605}
606
607#[must_use]
608pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
609 get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
610}
611
612#[must_use]
613pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
614 MODEL_CAPABILITIES
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
622 fn test_lookup_anthropic_opus_48() {
623 let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
624 assert_eq!(caps.context_window, Some(1_000_000));
625 assert_eq!(caps.max_output_tokens, Some(128_000));
626 assert!(caps.supports_thinking);
627 assert!(caps.supports_adaptive_thinking);
628 }
629
630 #[test]
631 fn test_lookup_anthropic_opus_46() {
632 let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
633 assert_eq!(caps.context_window, Some(1_000_000));
634 assert_eq!(caps.max_output_tokens, Some(128_000));
635 assert!(caps.supports_adaptive_thinking);
636 }
637
638 #[test]
639 fn test_lookup_anthropic_sonnet_46() {
640 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
641 assert_eq!(caps.context_window, Some(1_000_000));
642 assert_eq!(caps.max_output_tokens, Some(64_000));
643 assert!(caps.supports_adaptive_thinking);
644 }
645
646 #[test]
647 fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
648 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
649 assert!(!caps.supports_adaptive_thinking);
650 }
651
652 #[test]
653 fn test_lookup_openai_pricing() {
654 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
655 let pricing = caps.pricing.unwrap();
656 assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
657 assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
658 }
659
660 #[test]
661 fn test_lookup_openai_gpt54() {
662 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
663 assert_eq!(caps.context_window, Some(1_050_000));
664 assert_eq!(caps.max_output_tokens, Some(128_000));
665 assert!(caps.supports_thinking);
666 assert_eq!(caps.source_status, SourceStatus::Official);
667 }
668
669 #[test]
670 fn test_lookup_openai_gpt53_codex() {
671 let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
672 assert_eq!(caps.context_window, Some(400_000));
673 assert_eq!(caps.max_output_tokens, Some(120_000));
674 assert!(caps.supports_thinking);
675 assert_eq!(caps.source_status, SourceStatus::Official);
676 }
677
678 #[test]
679 fn test_lookup_gemini_preview_models() {
680 let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
681 assert_eq!(flash.context_window, Some(1_048_576));
682 assert!(flash.supports_thinking);
683
684 let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
685 assert_eq!(pro.max_output_tokens, Some(65_536));
686 assert!(pro.supports_thinking);
687 }
688
689 #[test]
690 fn test_estimate_cost_usd() {
691 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
692 let cost = caps
693 .estimate_cost_usd(&Usage {
694 input_tokens: 2_000,
695 output_tokens: 1_000,
696 cached_input_tokens: 0,
697 cache_creation_input_tokens: 0,
698 })
699 .unwrap();
700 assert!((cost - 0.0075).abs() < f64::EPSILON);
701 }
702
703 #[test]
704 fn test_estimate_cost_usd_with_cached_input() {
705 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
706 let cost = caps
707 .estimate_cost_usd(&Usage {
708 input_tokens: 2_000,
709 output_tokens: 1_000,
710 cached_input_tokens: 1_000,
711 cache_creation_input_tokens: 0,
712 })
713 .unwrap();
714 assert!((cost - 0.01775).abs() < f64::EPSILON);
715 }
716}