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>,
36 pub cache_write: Option<PricePoint>,
44 pub reasoning: Option<PricePoint>,
57 pub notes: Option<&'static str>,
58}
59
60struct InputBands {
65 plain: u32,
66 cache_read: u32,
67 cache_write: u32,
68}
69
70impl InputBands {
71 fn split(usage: &Usage) -> Self {
72 let cache_read = usage.cached_input_tokens.min(usage.input_tokens);
73 let remaining = usage.input_tokens.saturating_sub(cache_read);
74 let cache_write = usage.cache_creation_input_tokens.min(remaining);
75 Self {
76 plain: remaining.saturating_sub(cache_write),
77 cache_read,
78 cache_write,
79 }
80 }
81}
82
83impl Pricing {
84 #[must_use]
85 pub const fn flat(input: f64, output: f64) -> Self {
86 Self {
87 input: Some(PricePoint::new(input)),
88 output: Some(PricePoint::new(output)),
89 cached_input: None,
90 cache_write: None,
91 reasoning: None,
92 notes: None,
93 }
94 }
95
96 #[must_use]
97 pub const fn flat_with_cached(input: f64, output: f64, cached_input: f64) -> Self {
98 Self {
99 input: Some(PricePoint::new(input)),
100 output: Some(PricePoint::new(output)),
101 cached_input: Some(PricePoint::new(cached_input)),
102 cache_write: None,
103 reasoning: None,
104 notes: None,
105 }
106 }
107
108 #[must_use]
110 pub const fn with_cache_write(mut self, cache_write: f64) -> Self {
111 self.cache_write = Some(PricePoint::new(cache_write));
112 self
113 }
114
115 #[must_use]
117 pub const fn with_reasoning(mut self, reasoning: f64) -> Self {
118 self.reasoning = Some(PricePoint::new(reasoning));
119 self
120 }
121
122 #[must_use]
123 pub const fn with_notes(mut self, notes: &'static str) -> Self {
124 self.notes = Some(notes);
125 self
126 }
127
128 const fn band_rate(&self, band: Option<PricePoint>) -> Option<PricePoint> {
136 match band {
137 Some(rate) => Some(rate),
138 None => self.input,
139 }
140 }
141
142 #[must_use]
143 pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
144 let bands = InputBands::split(usage);
145
146 let mut input_cost: Option<f64> = None;
147 let mut add = |rate: Option<PricePoint>, tokens: u32| {
148 if let Some(rate) = rate {
149 *input_cost.get_or_insert(0.0) += rate.estimate_cost_usd(tokens);
150 }
151 };
152 add(self.input, bands.plain);
153 add(self.band_rate(self.cached_input), bands.cache_read);
154 add(self.band_rate(self.cache_write), bands.cache_write);
155
156 let output = output_rate(self.output, self.reasoning)
163 .map(|p| p.estimate_cost_usd(usage.output_tokens));
164 match (input_cost, output) {
165 (Some(input), Some(output)) => Some(input + output),
166 (Some(input), None) => Some(input),
167 (None, Some(output)) => Some(output),
168 (None, None) => None,
169 }
170 }
171}
172
173const fn output_rate(
177 output: Option<PricePoint>,
178 reasoning: Option<PricePoint>,
179) -> Option<PricePoint> {
180 match (output, reasoning) {
181 (Some(output), Some(reasoning)) => {
182 if reasoning.usd_per_million_tokens > output.usd_per_million_tokens {
183 Some(reasoning)
184 } else {
185 Some(output)
186 }
187 }
188 (rate, None) | (None, rate) => rate,
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct ModelCapabilities {
194 pub provider: &'static str,
195 pub model_id: &'static str,
196 pub context_window: Option<u32>,
197 pub max_output_tokens: Option<u32>,
198 pub pricing: Option<Pricing>,
199 pub supports_thinking: bool,
200 pub supports_adaptive_thinking: bool,
201 pub source_url: &'static str,
202 pub source_status: SourceStatus,
203 pub notes: Option<&'static str>,
204}
205
206impl ModelCapabilities {
207 #[must_use]
208 pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
209 self.pricing
210 .as_ref()
211 .and_then(|p| p.estimate_cost_usd(usage))
212 }
213}
214
215const ANTHROPIC_MODELS_URL: &str =
216 "https://docs.anthropic.com/en/docs/about-claude/models/all-models";
217const OPENAI_MODELS_URL: &str = "https://developers.openai.com/api/docs/models";
218const OPENAI_PRICING_URL: &str = "https://developers.openai.com/api/docs/pricing";
219const OPENAI_GPT56_SOL_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-sol";
220const OPENAI_GPT56_TERRA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-terra";
221const OPENAI_GPT56_LUNA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-luna";
222const OPENAI_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
223const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
224const OPENAI_GPT52_PRO_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.2-pro";
225const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
226const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
227
228const OPENROUTER_GLM51_URL: &str = "https://openrouter.ai/z-ai/glm-5.1";
231const ZAI_GLM5_PRICING_URL: &str = "https://docs.z.ai/guides/overview/pricing";
232const OPENROUTER_KIMI_K26_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.6";
233const OPENROUTER_KIMI_K25_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.5";
234const KIMI_K25_AA_URL: &str = "https://artificialanalysis.ai/models/kimi-k2-5";
235const OPENROUTER_KIMI_K2_THINKING_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2-thinking";
236const OPENROUTER_DEEPSEEK_V4_PRO_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-pro";
237const OPENROUTER_DEEPSEEK_V4_FLASH_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-flash";
238const DEEPSEEK_PRICING_URL: &str = "https://api-docs.deepseek.com/quick_start/pricing";
239const MINIMAX_PRICING_URL: &str = "https://platform.minimax.io/docs/guides/pricing-paygo";
240const OPENROUTER_MINIMAX_M25_URL: &str = "https://openrouter.ai/minimax/minimax-m2.5";
241
242const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
243 ModelCapabilities {
245 provider: "anthropic",
246 model_id: "claude-fable-5",
247 context_window: Some(1_000_000),
248 max_output_tokens: Some(128_000),
249 pricing: Some(Pricing::flat(10.0, 50.0).with_notes("Anthropic Fable 5 official pricing: $10 input / $50 output per 1M tokens.")),
250 supports_thinking: true,
251 supports_adaptive_thinking: true,
252 source_url: ANTHROPIC_MODELS_URL,
253 source_status: SourceStatus::Official,
254 notes: Some("Fable 5 is adaptive-only: adaptive thinking is always on (applies even when `thinking` is unset) and `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config. Raw chain of thought is never returned — thinking blocks arrive empty (the SDK requests thinking display=omitted). Safety classifiers may decline a request with stop_reason=refusal on an HTTP 200."),
255 },
256 ModelCapabilities {
257 provider: "anthropic",
258 model_id: "claude-opus-4-8",
259 context_window: Some(1_000_000),
260 max_output_tokens: Some(128_000),
261 pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 4.8 pricing matches the Opus 4.6 tier ($5/$25 per 1M); verify exact current SKU mapping before billing-critical use.")),
262 supports_thinking: true,
263 supports_adaptive_thinking: true,
264 source_url: ANTHROPIC_MODELS_URL,
265 source_status: SourceStatus::Derived,
266 notes: Some("Opus 4.8 requires adaptive thinking — `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config."),
267 },
268 ModelCapabilities {
269 provider: "anthropic",
270 model_id: "claude-opus-4-7",
271 context_window: Some(1_000_000),
272 max_output_tokens: Some(128_000),
273 pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 4.7 pricing matches the Opus 4.6 tier ($5/$25 per 1M); verify exact current SKU mapping before billing-critical use.")),
274 supports_thinking: true,
275 supports_adaptive_thinking: true,
276 source_url: ANTHROPIC_MODELS_URL,
277 source_status: SourceStatus::Derived,
278 notes: Some("Opus 4.7 requires adaptive thinking — `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config."),
279 },
280 ModelCapabilities {
281 provider: "anthropic",
282 model_id: "claude-opus-4-6",
283 context_window: Some(1_000_000),
284 max_output_tokens: Some(128_000),
285 pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 4.6 pricing from bundled Claude API guidance; verify exact current SKU mapping before billing-critical use.")),
286 supports_thinking: true,
287 supports_adaptive_thinking: true,
288 source_url: ANTHROPIC_MODELS_URL,
289 source_status: SourceStatus::Derived,
290 notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
291 },
292 ModelCapabilities {
293 provider: "anthropic",
294 model_id: "claude-sonnet-5",
295 context_window: Some(1_000_000),
296 max_output_tokens: Some(128_000),
297 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 5 standard pricing $3/$15 per 1M; introductory $2/$10 through 2026-08-31. A new tokenizer produces ~30% more tokens than Sonnet 4.6, so equivalent-text cost differs even at unchanged per-token rates.")),
298 supports_thinking: true,
299 supports_adaptive_thinking: true,
300 source_url: ANTHROPIC_MODELS_URL,
301 source_status: SourceStatus::Official,
302 notes: Some("Sonnet 5 is adaptive-only: adaptive thinking is on by default (applies even when `thinking` is unset) and `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API — same as Opus 4.8. Non-default sampling params (temperature/top_p/top_k) also return 400 (constraint inherited from Opus 4.7). Uses a new tokenizer (~30% more tokens than Sonnet 4.6)."),
303 },
304 ModelCapabilities {
305 provider: "anthropic",
306 model_id: "claude-sonnet-4-6",
307 context_window: Some(1_000_000),
308 max_output_tokens: Some(64_000),
309 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
310 supports_thinking: true,
311 supports_adaptive_thinking: true,
312 source_url: ANTHROPIC_MODELS_URL,
313 source_status: SourceStatus::Derived,
314 notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
315 },
316 ModelCapabilities {
317 provider: "anthropic",
318 model_id: "claude-sonnet-4-5-20250929",
319 context_window: Some(200_000),
320 max_output_tokens: Some(64_000),
321 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
322 supports_thinking: true,
323 supports_adaptive_thinking: false,
324 source_url: ANTHROPIC_MODELS_URL,
325 source_status: SourceStatus::Derived,
326 notes: None,
327 },
328 ModelCapabilities {
329 provider: "anthropic",
330 model_id: "claude-haiku-4-5-20251001",
331 context_window: Some(200_000),
332 max_output_tokens: Some(64_000),
333 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
334 supports_thinking: true,
335 supports_adaptive_thinking: false,
336 source_url: ANTHROPIC_MODELS_URL,
337 source_status: SourceStatus::Derived,
338 notes: None,
339 },
340 ModelCapabilities {
341 provider: "anthropic",
342 model_id: "claude-sonnet-4-20250514",
343 context_window: Some(200_000),
344 max_output_tokens: Some(64_000),
345 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
346 supports_thinking: true,
347 supports_adaptive_thinking: false,
348 source_url: ANTHROPIC_MODELS_URL,
349 source_status: SourceStatus::Derived,
350 notes: None,
351 },
352 ModelCapabilities {
353 provider: "anthropic",
354 model_id: "claude-opus-4-20250514",
355 context_window: Some(200_000),
356 max_output_tokens: Some(32_000),
357 pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
358 supports_thinking: true,
359 supports_adaptive_thinking: false,
360 source_url: ANTHROPIC_MODELS_URL,
361 source_status: SourceStatus::Derived,
362 notes: None,
363 },
364 ModelCapabilities {
365 provider: "anthropic",
366 model_id: "claude-3-5-sonnet-20241022",
367 context_window: Some(200_000),
368 max_output_tokens: Some(8_192),
369 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
370 supports_thinking: true,
371 supports_adaptive_thinking: false,
372 source_url: ANTHROPIC_MODELS_URL,
373 source_status: SourceStatus::Derived,
374 notes: None,
375 },
376 ModelCapabilities {
377 provider: "anthropic",
378 model_id: "claude-3-5-haiku-20241022",
379 context_window: Some(200_000),
380 max_output_tokens: Some(8_192),
381 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
382 supports_thinking: true,
383 supports_adaptive_thinking: false,
384 source_url: ANTHROPIC_MODELS_URL,
385 source_status: SourceStatus::Derived,
386 notes: None,
387 },
388 ModelCapabilities {
390 provider: "openai",
391 model_id: "gpt-5.6",
392 context_window: Some(1_050_000),
393 max_output_tokens: Some(128_000),
394 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
395 "Standard tier base rates. Cache writes cost $6.25/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
396 )),
397 supports_thinking: true,
398 supports_adaptive_thinking: true,
399 source_url: OPENAI_GPT56_SOL_URL,
400 source_status: SourceStatus::Official,
401 notes: Some("Official alias for GPT-5.6 Sol."),
402 },
403 ModelCapabilities {
404 provider: "openai",
405 model_id: "gpt-5.6-sol",
406 context_window: Some(1_050_000),
407 max_output_tokens: Some(128_000),
408 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
409 "Standard tier base rates. Cache writes cost $6.25/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
410 )),
411 supports_thinking: true,
412 supports_adaptive_thinking: true,
413 source_url: OPENAI_GPT56_SOL_URL,
414 source_status: SourceStatus::Official,
415 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
416 },
417 ModelCapabilities {
418 provider: "openai",
419 model_id: "gpt-5.6-terra",
420 context_window: Some(1_050_000),
421 max_output_tokens: Some(128_000),
422 pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
423 "Standard tier base rates. Cache writes cost $3.125/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
424 )),
425 supports_thinking: true,
426 supports_adaptive_thinking: true,
427 source_url: OPENAI_GPT56_TERRA_URL,
428 source_status: SourceStatus::Official,
429 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
430 },
431 ModelCapabilities {
432 provider: "openai",
433 model_id: "gpt-5.6-luna",
434 context_window: Some(1_050_000),
435 max_output_tokens: Some(128_000),
436 pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
437 "Standard tier base rates. Cache writes cost $1.25/M input tokens. Requests with more than 272K input tokens cost 2x input and 1.5x output for the full request.",
438 )),
439 supports_thinking: true,
440 supports_adaptive_thinking: true,
441 source_url: OPENAI_GPT56_LUNA_URL,
442 source_status: SourceStatus::Official,
443 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
444 },
445 ModelCapabilities {
446 provider: "openai",
447 model_id: "gpt-5.4",
448 context_window: Some(1_050_000),
449 max_output_tokens: Some(128_000),
450 pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
451 supports_thinking: true,
452 supports_adaptive_thinking: false,
453 source_url: OPENAI_GPT54_URL,
454 source_status: SourceStatus::Official,
455 notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
456 },
457 ModelCapabilities {
458 provider: "openai",
459 model_id: "gpt-5.3-codex",
460 context_window: Some(400_000),
461 max_output_tokens: Some(128_000),
462 pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
463 supports_thinking: true,
464 supports_adaptive_thinking: true,
465 source_url: OPENAI_GPT53_CODEX_URL,
466 source_status: SourceStatus::Official,
467 notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
468 },
469 ModelCapabilities {
470 provider: "openai",
471 model_id: "gpt-5",
472 context_window: Some(400_000),
473 max_output_tokens: Some(128_000),
474 pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
475 supports_thinking: false,
476 supports_adaptive_thinking: false,
477 source_url: OPENAI_PRICING_URL,
478 source_status: SourceStatus::Official,
479 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
480 },
481 ModelCapabilities {
482 provider: "openai",
483 model_id: "gpt-5-mini",
484 context_window: Some(400_000),
485 max_output_tokens: Some(128_000),
486 pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
487 supports_thinking: false,
488 supports_adaptive_thinking: false,
489 source_url: OPENAI_PRICING_URL,
490 source_status: SourceStatus::Official,
491 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
492 },
493 ModelCapabilities {
494 provider: "openai",
495 model_id: "gpt-5-nano",
496 context_window: Some(400_000),
497 max_output_tokens: Some(128_000),
498 pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
499 supports_thinking: false,
500 supports_adaptive_thinking: false,
501 source_url: OPENAI_PRICING_URL,
502 source_status: SourceStatus::Official,
503 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
504 },
505 ModelCapabilities {
506 provider: "openai",
507 model_id: "gpt-5.2-instant",
508 context_window: Some(400_000),
509 max_output_tokens: Some(128_000),
510 pricing: None,
511 supports_thinking: false,
512 supports_adaptive_thinking: false,
513 source_url: OPENAI_MODELS_URL,
514 source_status: SourceStatus::Unverified,
515 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
516 },
517 ModelCapabilities {
518 provider: "openai",
519 model_id: "gpt-5.2-thinking",
520 context_window: Some(400_000),
521 max_output_tokens: Some(128_000),
522 pricing: None,
523 supports_thinking: true,
524 supports_adaptive_thinking: false,
525 source_url: OPENAI_MODELS_URL,
526 source_status: SourceStatus::Unverified,
527 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
528 },
529 ModelCapabilities {
530 provider: "openai",
531 model_id: "gpt-5.2-pro",
532 context_window: Some(400_000),
533 max_output_tokens: Some(128_000),
534 pricing: Some(Pricing::flat(21.0, 168.0)),
535 supports_thinking: true,
536 supports_adaptive_thinking: false,
537 source_url: OPENAI_GPT52_PRO_URL,
538 source_status: SourceStatus::Official,
539 notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
540 },
541 ModelCapabilities {
542 provider: "openai",
543 model_id: "gpt-5.2-codex",
544 context_window: Some(400_000),
545 max_output_tokens: Some(128_000),
546 pricing: None,
547 supports_thinking: false,
548 supports_adaptive_thinking: false,
549 source_url: OPENAI_MODELS_URL,
550 source_status: SourceStatus::Unverified,
551 notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
552 },
553 ModelCapabilities {
554 provider: "openai",
555 model_id: "o3",
556 context_window: Some(200_000),
557 max_output_tokens: Some(100_000),
558 pricing: Some(Pricing::flat(1.0, 4.0)),
559 supports_thinking: true,
560 supports_adaptive_thinking: false,
561 source_url: OPENAI_PRICING_URL,
562 source_status: SourceStatus::Official,
563 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
564 },
565 ModelCapabilities {
566 provider: "openai",
567 model_id: "o3-mini",
568 context_window: Some(200_000),
569 max_output_tokens: Some(100_000),
570 pricing: Some(Pricing::flat(0.55, 2.20)),
571 supports_thinking: true,
572 supports_adaptive_thinking: false,
573 source_url: OPENAI_PRICING_URL,
574 source_status: SourceStatus::Official,
575 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
576 },
577 ModelCapabilities {
578 provider: "openai",
579 model_id: "o4-mini",
580 context_window: Some(200_000),
581 max_output_tokens: Some(100_000),
582 pricing: Some(Pricing::flat(0.55, 2.20)),
583 supports_thinking: true,
584 supports_adaptive_thinking: false,
585 source_url: OPENAI_PRICING_URL,
586 source_status: SourceStatus::Official,
587 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
588 },
589 ModelCapabilities {
590 provider: "openai",
591 model_id: "o1",
592 context_window: Some(200_000),
593 max_output_tokens: Some(100_000),
594 pricing: Some(Pricing::flat(7.50, 30.0)),
595 supports_thinking: true,
596 supports_adaptive_thinking: false,
597 source_url: OPENAI_PRICING_URL,
598 source_status: SourceStatus::Official,
599 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
600 },
601 ModelCapabilities {
602 provider: "openai",
603 model_id: "o1-mini",
604 context_window: Some(200_000),
605 max_output_tokens: Some(100_000),
606 pricing: Some(Pricing::flat(0.55, 2.20)),
607 supports_thinking: true,
608 supports_adaptive_thinking: false,
609 source_url: OPENAI_PRICING_URL,
610 source_status: SourceStatus::Official,
611 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
612 },
613 ModelCapabilities {
614 provider: "openai",
615 model_id: "gpt-4.1",
616 context_window: Some(1_000_000),
617 max_output_tokens: Some(16_384),
618 pricing: Some(Pricing::flat(1.0, 4.0)),
619 supports_thinking: false,
620 supports_adaptive_thinking: false,
621 source_url: OPENAI_PRICING_URL,
622 source_status: SourceStatus::Official,
623 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
624 },
625 ModelCapabilities {
626 provider: "openai",
627 model_id: "gpt-4.1-mini",
628 context_window: Some(1_000_000),
629 max_output_tokens: Some(16_384),
630 pricing: Some(Pricing::flat(0.20, 0.80)),
631 supports_thinking: false,
632 supports_adaptive_thinking: false,
633 source_url: OPENAI_PRICING_URL,
634 source_status: SourceStatus::Official,
635 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
636 },
637 ModelCapabilities {
638 provider: "openai",
639 model_id: "gpt-4.1-nano",
640 context_window: Some(1_000_000),
641 max_output_tokens: Some(16_384),
642 pricing: Some(Pricing::flat(0.05, 0.20)),
643 supports_thinking: false,
644 supports_adaptive_thinking: false,
645 source_url: OPENAI_PRICING_URL,
646 source_status: SourceStatus::Official,
647 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
648 },
649 ModelCapabilities {
650 provider: "openai",
651 model_id: "gpt-4o",
652 context_window: Some(128_000),
653 max_output_tokens: Some(16_384),
654 pricing: Some(Pricing::flat(1.25, 5.0)),
655 supports_thinking: false,
656 supports_adaptive_thinking: false,
657 source_url: OPENAI_PRICING_URL,
658 source_status: SourceStatus::Official,
659 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
660 },
661 ModelCapabilities {
662 provider: "openai",
663 model_id: "gpt-4o-mini",
664 context_window: Some(128_000),
665 max_output_tokens: Some(16_384),
666 pricing: Some(Pricing::flat(0.075, 0.30)),
667 supports_thinking: false,
668 supports_adaptive_thinking: false,
669 source_url: OPENAI_PRICING_URL,
670 source_status: SourceStatus::Official,
671 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
672 },
673 ModelCapabilities {
675 provider: "gemini",
676 model_id: "gemini-3.1-pro-preview",
677 context_window: Some(1_048_576),
678 max_output_tokens: Some(65_536),
679 pricing: Some(Pricing::flat(2.0, 12.0).with_notes("Official pricing for prompts <= 200K tokens. For prompts > 200K, pricing increases to $4 input / $18 output per 1M tokens.")),
680 supports_thinking: true,
681 supports_adaptive_thinking: false,
682 source_url: GOOGLE_PRICING_URL,
683 source_status: SourceStatus::Official,
684 notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
685 },
686 ModelCapabilities {
687 provider: "gemini",
688 model_id: "gemini-3.1-pro",
689 context_window: Some(1_048_576),
690 max_output_tokens: Some(65_536),
691 pricing: Some(Pricing::flat(2.0, 12.0).with_notes("Legacy alias retained for compatibility. For prompts > 200K, pricing increases to $4 input / $18 output per 1M tokens.")),
692 supports_thinking: true,
693 supports_adaptive_thinking: false,
694 source_url: GOOGLE_PRICING_URL,
695 source_status: SourceStatus::Derived,
696 notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
697 },
698 ModelCapabilities {
699 provider: "gemini",
700 model_id: "gemini-3.1-flash-lite-preview",
701 context_window: Some(1_048_576),
702 max_output_tokens: Some(65_536),
703 pricing: None,
704 supports_thinking: true,
705 supports_adaptive_thinking: false,
706 source_url: GOOGLE_MODELS_URL,
707 source_status: SourceStatus::Unverified,
708 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
709 },
710 ModelCapabilities {
711 provider: "gemini",
712 model_id: "gemini-3-flash-preview",
713 context_window: Some(1_048_576),
714 max_output_tokens: Some(65_536),
715 pricing: None,
716 supports_thinking: true,
717 supports_adaptive_thinking: false,
718 source_url: GOOGLE_MODELS_URL,
719 source_status: SourceStatus::Unverified,
720 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
721 },
722 ModelCapabilities {
723 provider: "gemini",
724 model_id: "gemini-3.0-flash",
725 context_window: Some(1_048_576),
726 max_output_tokens: Some(65_536),
727 pricing: None,
728 supports_thinking: true,
729 supports_adaptive_thinking: false,
730 source_url: GOOGLE_MODELS_URL,
731 source_status: SourceStatus::Derived,
732 notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
733 },
734 ModelCapabilities {
735 provider: "gemini",
736 model_id: "gemini-3.0-pro",
737 context_window: Some(1_048_576),
738 max_output_tokens: Some(65_536),
739 pricing: None,
740 supports_thinking: true,
741 supports_adaptive_thinking: false,
742 source_url: GOOGLE_MODELS_URL,
743 source_status: SourceStatus::Unverified,
744 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
745 },
746 ModelCapabilities {
747 provider: "gemini",
748 model_id: "gemini-2.5-flash",
749 context_window: Some(1_000_000),
750 max_output_tokens: Some(65_536),
751 pricing: Some(Pricing::flat(0.30, 2.50).with_notes("Official text/image/video pricing. Audio input is priced separately at $1.00 / 1M tokens.")),
752 supports_thinking: true,
753 supports_adaptive_thinking: false,
754 source_url: GOOGLE_PRICING_URL,
755 source_status: SourceStatus::Official,
756 notes: Some("Official docs state output pricing includes thinking tokens."),
757 },
758 ModelCapabilities {
759 provider: "gemini",
760 model_id: "gemini-2.5-pro",
761 context_window: Some(1_000_000),
762 max_output_tokens: Some(65_536),
763 pricing: None,
764 supports_thinking: true,
765 supports_adaptive_thinking: false,
766 source_url: GOOGLE_MODELS_URL,
767 source_status: SourceStatus::Unverified,
768 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
769 },
770 ModelCapabilities {
771 provider: "gemini",
772 model_id: "gemini-2.0-flash",
773 context_window: Some(1_000_000),
774 max_output_tokens: Some(8_192),
775 pricing: Some(Pricing::flat(0.10, 0.40).with_notes("Official text/image/video pricing. Audio input is priced separately at $0.70 / 1M tokens.")),
776 supports_thinking: false,
777 supports_adaptive_thinking: false,
778 source_url: GOOGLE_PRICING_URL,
779 source_status: SourceStatus::Official,
780 notes: None,
781 },
782 ModelCapabilities {
783 provider: "gemini",
784 model_id: "gemini-2.0-flash-lite",
785 context_window: Some(1_000_000),
786 max_output_tokens: Some(8_192),
787 pricing: Some(Pricing::flat(0.075, 0.30)),
788 supports_thinking: false,
789 supports_adaptive_thinking: false,
790 source_url: GOOGLE_PRICING_URL,
791 source_status: SourceStatus::Official,
792 notes: None,
793 },
794 ModelCapabilities {
798 provider: "openai",
799 model_id: "z-ai/glm-5.1",
800 context_window: Some(202_752),
801 max_output_tokens: Some(131_072),
802 pricing: Some(Pricing::flat(0.98, 3.08).with_notes("OpenRouter rate for z-ai/glm-5.1: input $0.98/M, output $3.08/M.")),
803 supports_thinking: true,
804 supports_adaptive_thinking: false,
805 source_url: OPENROUTER_GLM51_URL,
806 source_status: SourceStatus::Derived,
807 notes: Some("GLM-5.1 (z.ai/Zhipu) via OpenRouter slug. Reasoning/thinking model; context 203K (=202,752). max_output 128K from z.ai GLM-5.1 docs, sized generously for hidden reasoning + answer. Released ~Apr 7, 2026."),
808 },
809 ModelCapabilities {
810 provider: "openai",
811 model_id: "glm-5",
812 context_window: Some(200_000),
813 max_output_tokens: Some(131_072),
814 pricing: Some(Pricing::flat(1.0, 3.2).with_notes("Native z.ai pricing: input $1.0/M, output $3.2/M (higher than the OpenRouter GLM-5 rate of $0.60/$1.92).")),
815 supports_thinking: true,
816 supports_adaptive_thinking: false,
817 source_url: ZAI_GLM5_PRICING_URL,
818 source_status: SourceStatus::Derived,
819 notes: Some("Native z.ai constructor model string `glm-5`. Reasoning/thinking model; 200K context, 128K (131072) max output per docs.z.ai/guides/llm/glm-5. Native pricing used for the native route. Released ~Feb 11, 2026."),
820 },
821 ModelCapabilities {
822 provider: "openai",
823 model_id: "moonshotai/kimi-k2.6",
824 context_window: Some(262_144),
825 max_output_tokens: Some(65_536),
826 pricing: Some(Pricing::flat(0.684, 3.42).with_notes("OpenRouter rate for moonshotai/kimi-k2.6: input $0.684/M, output $3.42/M.")),
827 supports_thinking: false,
828 supports_adaptive_thinking: false,
829 source_url: OPENROUTER_KIMI_K26_URL,
830 source_status: SourceStatus::Derived,
831 notes: Some("Exact OpenRouter slug (note the dot). Hybrid model marketed/used as a non-reasoning coding+multimodal model, so supports_thinking=false (use moonshotai/kimi-k2-thinking for the dedicated reasoning model). Context 262,144; 65536 is a generous app-side completion budget within the window."),
832 },
833 ModelCapabilities {
834 provider: "openai",
835 model_id: "moonshotai/kimi-k2.5",
836 context_window: Some(262_144),
837 max_output_tokens: Some(32_768),
838 pricing: Some(Pricing::flat(0.4, 1.9).with_notes("OpenRouter rate for moonshotai/kimi-k2.5: input $0.40/M, output $1.90/M.")),
839 supports_thinking: false,
840 supports_adaptive_thinking: false,
841 source_url: OPENROUTER_KIMI_K25_URL,
842 source_status: SourceStatus::Derived,
843 notes: Some("OpenRouter route for the model the native constructor names 'kimi-k2.5'. Treated as non-reasoning (visual-coding + agentic tool-calling) on OpenRouter. Context 262,144; 32768 is a generous app-side completion budget within the window."),
844 },
845 ModelCapabilities {
846 provider: "openai",
847 model_id: "kimi-k2.5",
848 context_window: Some(262_144),
849 max_output_tokens: Some(32_768),
850 pricing: Some(Pricing::flat(0.6, 3.0).with_notes("Native Moonshot estimate from Artificial Analysis (~$0.58 in / $3.00 out); input rounded up to $0.60 to stay conservative for budget reservation.")),
851 supports_thinking: false,
852 supports_adaptive_thinking: false,
853 source_url: KIMI_K25_AA_URL,
854 source_status: SourceStatus::Unverified,
855 notes: Some("Exact native model_id used by the native constructor (Moonshot platform.kimi.ai base_url). Native pricing not on the first-party table (only k2.6 is enumerated); figures derived from Artificial Analysis. Context 262,144; 32768 is a generous within-window completion budget."),
856 },
857 ModelCapabilities {
858 provider: "openai",
859 model_id: "kimi-k2-thinking",
860 context_window: Some(262_144),
861 max_output_tokens: Some(131_072),
862 pricing: Some(Pricing::flat(0.6, 2.5).with_notes("Cross-provider median for kimi-k2-thinking (OpenRouter/Artificial Analysis): input $0.60/M, output $2.50/M, used as a conservative native estimate.")),
863 supports_thinking: true,
864 supports_adaptive_thinking: false,
865 source_url: OPENROUTER_KIMI_K2_THINKING_URL,
866 source_status: SourceStatus::Unverified,
867 notes: Some("Exact native model_id used by the native constructor; a REASONING model (emits hidden chain-of-thought before the answer). Native Moonshot base_url. First-party pricing could not be isolated; figures are the cross-provider median. Context 262,144; max_output 131072 sized generously for reasoning tokens, within the window."),
868 },
869 ModelCapabilities {
870 provider: "openai",
871 model_id: "deepseek/deepseek-v4-pro",
872 context_window: Some(1_048_576),
873 max_output_tokens: Some(384_000),
874 pricing: Some(Pricing::flat(0.44, 0.87).with_notes("OpenRouter effective post-promo rate ($0.435 in rounded up to $0.44 / $0.87 out). Pre-promo regular rate was $1.74/$3.48.")),
875 supports_thinking: true,
876 supports_adaptive_thinking: false,
877 source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
878 source_status: SourceStatus::Derived,
879 notes: Some("Primary model named in forge config; exact OpenRouter slug. Large MoE (1.6T total / 49B active), released 2026-04-24. Reasoning/thinking model; DeepSeek returns the answer in `content` and chain-of-thought in a separate `reasoning_content` field, which must be echoed back in subsequent thinking-mode turns or the API returns 400. Max output 384K (DeepSeek ceiling), sized generously for reasoning."),
880 },
881 ModelCapabilities {
882 provider: "openai",
883 model_id: "deepseek-v4-pro",
884 context_window: Some(1_048_576),
885 max_output_tokens: Some(384_000),
886 pricing: Some(Pricing::flat_with_cached(0.44, 0.87, 0.003_625).with_notes("Official DeepSeek pricing: input cache-MISS $0.435/M (rounded up to $0.44), cache-HIT $0.003625/M, output $0.87/M.")),
887 supports_thinking: true,
888 supports_adaptive_thinking: false,
889 source_url: DEEPSEEK_PRICING_URL,
890 source_status: SourceStatus::Derived,
891 notes: Some("Native DeepSeek API model id 'deepseek-v4-pro' (no vendor prefix). 1M context, 384K max output. Reasoning/thinking model; separate `reasoning_content` that must be echoed back in multi-turn thinking-mode requests or you get a 400. Legacy ids deepseek-reasoner/deepseek-chat now map to V4-FLASH, not Pro."),
892 },
893 ModelCapabilities {
894 provider: "openai",
895 model_id: "deepseek/deepseek-v4-flash",
896 context_window: Some(1_048_576),
897 max_output_tokens: Some(384_000),
898 pricing: Some(Pricing::flat(0.15, 0.28).with_notes("DeepSeek list rate rounded up ($0.14 in -> $0.15 / $0.28 out) used instead of OpenRouter's lower fluctuating effective rate so consumers never under-reserve budget.")),
899 supports_thinking: true,
900 supports_adaptive_thinking: false,
901 source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
902 source_status: SourceStatus::Derived,
903 notes: Some("Sibling V4 model (cheaper routing target). Efficiency MoE (284B total / 13B active), released 2026-04-24. Reasoning/thinking model with the same reasoning_content split + mandatory pass-back-or-400 behavior as V4 Pro. Max output 384K per DeepSeek docs."),
904 },
905 ModelCapabilities {
906 provider: "openai",
907 model_id: "deepseek-v4-flash",
908 context_window: Some(1_048_576),
909 max_output_tokens: Some(384_000),
910 pricing: Some(Pricing::flat_with_cached(0.14, 0.28, 0.002_8).with_notes("Official DeepSeek pricing: input cache-MISS $0.14/M, cache-HIT $0.0028/M, output $0.28/M.")),
911 supports_thinking: true,
912 supports_adaptive_thinking: false,
913 source_url: DEEPSEEK_PRICING_URL,
914 source_status: SourceStatus::Derived,
915 notes: Some("Native DeepSeek API model id 'deepseek-v4-flash'. 1M context, 384K max output. Reasoning/thinking model; same content/reasoning_content split and mandatory pass-back in thinking mode. Legacy aliases deepseek-chat/deepseek-reasoner now resolve to this Flash model."),
916 },
917 ModelCapabilities {
918 provider: "openai",
919 model_id: "MiniMax-M2.5",
920 context_window: Some(204_800),
921 max_output_tokens: Some(131_072),
922 pricing: Some(Pricing::flat_with_cached(0.3, 1.2, 0.03).with_notes("Native MiniMax first-party pricing: input $0.30/M, output $1.20/M, cache-read input $0.03/M (platform.minimax.io PAYG).")),
923 supports_thinking: true,
924 supports_adaptive_thinking: false,
925 source_url: MINIMAX_PRICING_URL,
926 source_status: SourceStatus::Derived,
927 notes: Some("Native agent-sdk constructor model string 'MiniMax-M2.5' (api.minimax.io, OpenAI-compatible). Reasoning/thinking model; emits chain-of-thought in <think>...</think> tags and supports interleaved thinking. Context 204,800; max_output 131072 sized generously for hidden reasoning + answer within the window."),
928 },
929 ModelCapabilities {
930 provider: "openai",
931 model_id: "minimax/minimax-m2.5",
932 context_window: Some(204_800),
933 max_output_tokens: Some(131_072),
934 pricing: Some(Pricing::flat(0.15, 1.15).with_notes("OpenRouter rate for minimax/minimax-m2.5: input $0.15/M, output $1.15/M (lower than MiniMax's $0.30/$1.20 first-party rate; OpenRouter prices can fluctuate, so reserve conservatively).")),
935 supports_thinking: true,
936 supports_adaptive_thinking: false,
937 source_url: OPENROUTER_MINIMAX_M25_URL,
938 source_status: SourceStatus::Derived,
939 notes: Some("OpenRouter slug 'minimax/minimax-m2.5' (same M2.5 weights as native). Reasoning/thinking model. Context 204,800; max_output 131072 sized generously for hidden reasoning tokens before the answer."),
940 },
941];
942
943#[must_use]
944pub fn get_model_capabilities(
945 provider: &str,
946 model_id: &str,
947) -> Option<&'static ModelCapabilities> {
948 MODEL_CAPABILITIES.iter().find(|caps| {
949 caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
950 })
951}
952
953#[must_use]
954pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
955 get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
956}
957
958#[must_use]
959pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
960 MODEL_CAPABILITIES
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966
967 #[test]
972 fn output_bills_at_the_dearer_reasoning_rate() -> anyhow::Result<()> {
973 use anyhow::Context;
974 let pricing = Pricing::flat(0.7, 2.8).with_reasoning(8.4);
975 let usage = Usage {
976 input_tokens: 1_000_000,
977 output_tokens: 1_000_000,
978 cached_input_tokens: 0,
979 cache_creation_input_tokens: 0,
980 };
981 let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
983 assert!((cost - 9.1).abs() < 1e-9, "unexpected cost: {cost}");
984 Ok(())
985 }
986
987 #[test]
991 fn output_keeps_the_dearer_output_rate() -> anyhow::Result<()> {
992 use anyhow::Context;
993 let pricing = Pricing::flat(1.0, 8.0).with_reasoning(3.0);
994 let usage = Usage {
995 input_tokens: 0,
996 output_tokens: 1_000_000,
997 cached_input_tokens: 0,
998 cache_creation_input_tokens: 0,
999 };
1000 let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1002 assert!((cost - 8.0).abs() < 1e-9, "unexpected cost: {cost}");
1003 Ok(())
1004 }
1005
1006 #[test]
1007 fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
1008 use anyhow::Context;
1009
1010 let caps = get_model_capabilities("anthropic", "claude-fable-5")
1011 .context("claude-fable-5 capabilities missing")?;
1012 assert_eq!(caps.context_window, Some(1_000_000));
1013 assert_eq!(caps.max_output_tokens, Some(128_000));
1014 assert!(caps.supports_thinking);
1015 assert!(caps.supports_adaptive_thinking);
1016 assert_eq!(caps.source_status, SourceStatus::Official);
1017 let pricing = caps.pricing.context("pricing missing")?;
1018 let input = pricing.input.context("input price missing")?;
1019 let output = pricing.output.context("output price missing")?;
1020 assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
1021 assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
1022 Ok(())
1023 }
1024
1025 #[test]
1026 fn test_lookup_anthropic_opus_48() {
1027 let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
1028 assert_eq!(caps.context_window, Some(1_000_000));
1029 assert_eq!(caps.max_output_tokens, Some(128_000));
1030 assert!(caps.supports_thinking);
1031 assert!(caps.supports_adaptive_thinking);
1032 }
1033
1034 #[test]
1035 fn test_lookup_anthropic_opus_46() {
1036 let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
1037 assert_eq!(caps.context_window, Some(1_000_000));
1038 assert_eq!(caps.max_output_tokens, Some(128_000));
1039 assert!(caps.supports_adaptive_thinking);
1040 }
1041
1042 #[test]
1043 fn test_lookup_anthropic_sonnet_5() {
1044 let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
1045 assert_eq!(caps.context_window, Some(1_000_000));
1046 assert_eq!(caps.max_output_tokens, Some(128_000));
1047 assert!(caps.supports_thinking);
1048 assert!(caps.supports_adaptive_thinking);
1050 }
1051
1052 #[test]
1053 fn test_lookup_anthropic_sonnet_46() {
1054 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
1055 assert_eq!(caps.context_window, Some(1_000_000));
1056 assert_eq!(caps.max_output_tokens, Some(64_000));
1057 assert!(caps.supports_adaptive_thinking);
1058 }
1059
1060 #[test]
1061 fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
1062 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
1063 assert!(!caps.supports_adaptive_thinking);
1064 }
1065
1066 #[test]
1067 fn test_lookup_openai_pricing() {
1068 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1069 let pricing = caps.pricing.unwrap();
1070 assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
1071 assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1072 }
1073
1074 #[test]
1075 fn test_lookup_openai_gpt54() {
1076 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1077 assert_eq!(caps.context_window, Some(1_050_000));
1078 assert_eq!(caps.max_output_tokens, Some(128_000));
1079 assert!(caps.supports_thinking);
1080 assert_eq!(caps.source_status, SourceStatus::Official);
1081 }
1082
1083 #[test]
1084 fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
1085 use anyhow::Context;
1086
1087 let caps = get_model_capabilities("openai", "gpt-5.2-pro")
1088 .context("gpt-5.2-pro capabilities missing")?;
1089 assert_eq!(caps.context_window, Some(400_000));
1090 assert_eq!(caps.max_output_tokens, Some(128_000));
1091 assert!(caps.supports_thinking);
1092 assert_eq!(caps.source_status, SourceStatus::Official);
1093 let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
1094 let input = pricing.input.context("input price missing")?;
1095 let output = pricing.output.context("output price missing")?;
1096 assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
1097 assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
1098 Ok(())
1099 }
1100
1101 #[test]
1102 fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
1103 use anyhow::Context as _;
1104
1105 for (model_id, input, cached_input, output, cache_write_note) in [
1106 ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
1107 ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
1108 ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
1109 ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
1110 ] {
1111 let caps = get_model_capabilities("openai", model_id)
1112 .with_context(|| format!("{model_id} capabilities missing"))?;
1113 assert_eq!(caps.context_window, Some(1_050_000));
1114 assert_eq!(caps.max_output_tokens, Some(128_000));
1115 assert!(caps.supports_thinking);
1116 assert!(caps.supports_adaptive_thinking);
1117 assert_eq!(caps.source_status, SourceStatus::Official);
1118 let pricing = caps
1119 .pricing
1120 .with_context(|| format!("{model_id} pricing missing"))?;
1121 assert_eq!(pricing.input, Some(PricePoint::new(input)));
1122 assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
1123 assert_eq!(pricing.output, Some(PricePoint::new(output)));
1124 assert!(pricing.notes.is_some_and(|notes| {
1125 notes.contains(cache_write_note) && notes.contains("more than 272K")
1126 }));
1127 }
1128
1129 Ok(())
1130 }
1131
1132 #[test]
1133 fn test_lookup_openai_gpt53_codex() {
1134 let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
1135 assert_eq!(caps.context_window, Some(400_000));
1136 assert_eq!(caps.max_output_tokens, Some(128_000));
1137 assert!(caps.supports_adaptive_thinking);
1138 assert!(caps.supports_thinking);
1139 assert_eq!(caps.source_status, SourceStatus::Official);
1140 }
1141
1142 #[test]
1143 fn test_lookup_gemini_preview_models() {
1144 let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1145 assert_eq!(flash.context_window, Some(1_048_576));
1146 assert!(flash.supports_thinking);
1147
1148 let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1149 assert_eq!(pro.max_output_tokens, Some(65_536));
1150 assert!(pro.supports_thinking);
1151 }
1152
1153 #[test]
1154 fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1155 let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1157 assert!(deepseek.supports_thinking);
1158 assert_eq!(deepseek.max_output_tokens, Some(384_000));
1159 let pricing = deepseek.pricing.unwrap();
1160 assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1161 assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1162
1163 let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1165 assert!(glm.supports_thinking);
1166 assert_eq!(glm.max_output_tokens, Some(131_072));
1167 let glm_pricing = glm.pricing.unwrap();
1168 assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1169 assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1170
1171 let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1173 assert!(kimi_thinking.supports_thinking);
1174 assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1175 assert!(
1176 kimi_thinking
1177 .pricing
1178 .unwrap()
1179 .output
1180 .unwrap()
1181 .usd_per_million_tokens
1182 > 0.0
1183 );
1184 }
1185
1186 #[test]
1187 fn test_lookup_open_non_reasoning_kimi_models() {
1188 let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1190 assert!(!k26.supports_thinking);
1191 assert_eq!(k26.max_output_tokens, Some(65_536));
1192 assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1193
1194 let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1195 assert!(!k25_native.supports_thinking);
1196 assert_eq!(k25_native.max_output_tokens, Some(32_768));
1197 }
1198
1199 #[test]
1200 fn test_lookup_all_open_models_resolve() {
1201 for model_id in [
1204 "z-ai/glm-5.1",
1205 "glm-5",
1206 "moonshotai/kimi-k2.6",
1207 "moonshotai/kimi-k2.5",
1208 "kimi-k2.5",
1209 "kimi-k2-thinking",
1210 "deepseek/deepseek-v4-pro",
1211 "deepseek-v4-pro",
1212 "deepseek/deepseek-v4-flash",
1213 "deepseek-v4-flash",
1214 "MiniMax-M2.5",
1215 "minimax/minimax-m2.5",
1216 ] {
1217 let caps = get_model_capabilities("openai", model_id)
1218 .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1219 assert!(
1220 caps.pricing.is_some(),
1221 "pricing should be populated for {model_id}"
1222 );
1223 assert!(
1224 caps.max_output_tokens.is_some_and(|m| m > 0),
1225 "max_output_tokens should be non-zero for {model_id}"
1226 );
1227 assert!(
1228 caps.context_window.is_some_and(|c| c > 0),
1229 "context_window should be non-zero for {model_id}"
1230 );
1231 }
1232 }
1233
1234 #[test]
1235 fn test_lookup_minimax_native_pricing() {
1236 let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1237 assert!(native.supports_thinking);
1238 let pricing = native.pricing.unwrap();
1239 assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1240 assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1241 assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1244 }
1245
1246 #[test]
1247 fn test_estimate_cost_usd() {
1248 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1249 let cost = caps
1250 .estimate_cost_usd(&Usage {
1251 input_tokens: 2_000,
1252 output_tokens: 1_000,
1253 cached_input_tokens: 0,
1254 cache_creation_input_tokens: 0,
1255 })
1256 .unwrap();
1257 assert!((cost - 0.0075).abs() < f64::EPSILON);
1258 }
1259
1260 #[test]
1261 fn test_estimate_cost_usd_with_cached_input() {
1262 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1263 let cost = caps
1264 .estimate_cost_usd(&Usage {
1265 input_tokens: 2_000,
1266 output_tokens: 1_000,
1267 cached_input_tokens: 1_000,
1268 cache_creation_input_tokens: 0,
1269 })
1270 .unwrap();
1271 assert!((cost - 0.01775).abs() < f64::EPSILON);
1272 }
1273}