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 rejects_budget_thinking: bool,
209 pub source_url: &'static str,
210 pub source_status: SourceStatus,
211 pub notes: Option<&'static str>,
212}
213
214impl ModelCapabilities {
215 #[must_use]
216 pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
217 self.pricing
218 .as_ref()
219 .and_then(|p| p.estimate_cost_usd(usage))
220 }
221}
222
223const ANTHROPIC_MODELS_URL: &str =
224 "https://docs.anthropic.com/en/docs/about-claude/models/all-models";
225const OPENAI_MODELS_URL: &str = "https://developers.openai.com/api/docs/models";
226const OPENAI_PRICING_URL: &str = "https://developers.openai.com/api/docs/pricing";
227const OPENAI_GPT56_SOL_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-sol";
228const OPENAI_GPT56_TERRA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-terra";
229const OPENAI_GPT56_LUNA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-luna";
230const OPENAI_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
231const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
232const OPENAI_GPT52_PRO_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.2-pro";
233const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
234const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
235
236const OPENROUTER_GLM51_URL: &str = "https://openrouter.ai/z-ai/glm-5.1";
239const ZAI_GLM5_PRICING_URL: &str = "https://docs.z.ai/guides/overview/pricing";
240const OPENROUTER_KIMI_K26_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.6";
241const OPENROUTER_KIMI_K25_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.5";
242const KIMI_K25_AA_URL: &str = "https://artificialanalysis.ai/models/kimi-k2-5";
243const OPENROUTER_KIMI_K2_THINKING_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2-thinking";
244const OPENROUTER_DEEPSEEK_V4_PRO_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-pro";
245const OPENROUTER_DEEPSEEK_V4_FLASH_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-flash";
246const DEEPSEEK_PRICING_URL: &str = "https://api-docs.deepseek.com/quick_start/pricing";
247const MINIMAX_PRICING_URL: &str = "https://platform.minimax.io/docs/guides/pricing-paygo";
248const OPENROUTER_MINIMAX_M25_URL: &str = "https://openrouter.ai/minimax/minimax-m2.5";
249
250const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
251 ModelCapabilities {
253 provider: "anthropic",
254 model_id: "claude-fable-5",
255 context_window: Some(1_000_000),
256 max_output_tokens: Some(128_000),
257 pricing: Some(Pricing::flat(10.0, 50.0).with_notes("Anthropic Fable 5 official pricing: $10 input / $50 output per 1M tokens.")),
258 supports_thinking: true,
259 supports_adaptive_thinking: true,
260 rejects_budget_thinking: true,
261 source_url: ANTHROPIC_MODELS_URL,
262 source_status: SourceStatus::Official,
263 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."),
264 },
265 ModelCapabilities {
266 provider: "anthropic",
267 model_id: "claude-opus-5",
268 context_window: Some(1_000_000),
269 max_output_tokens: Some(128_000),
270 pricing: Some(Pricing::flat(5.0, 25.0).with_notes("Anthropic Opus 5 official pricing: $5 input / $25 output per 1M tokens. Fast mode (`speed: \"fast\"`, not implemented by this SDK) is billed at $10/$50. Uses the Opus 4.7 tokenizer, which produces ~30% more tokens than Opus 4.6 and earlier for the same text.")),
271 supports_thinking: true,
272 supports_adaptive_thinking: true,
273 rejects_budget_thinking: true,
274 source_url: ANTHROPIC_MODELS_URL,
275 source_status: SourceStatus::Official,
276 notes: Some("Opus 5 rejects budget thinking — extended thinking (`thinking.type: \"enabled\"`) is not offered, so `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API (the SDK fails fast in validate_thinking_config). Adaptive is supported but optional: an effort level can be sent without it via `ThinkingConfig::default_with_effort`. When effort is unset the Claude API defaults to `high`."),
277 },
278 ModelCapabilities {
279 provider: "anthropic",
280 model_id: "claude-opus-4-8",
281 context_window: Some(1_000_000),
282 max_output_tokens: Some(128_000),
283 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.")),
284 supports_thinking: true,
285 supports_adaptive_thinking: true,
286 rejects_budget_thinking: true,
287 source_url: ANTHROPIC_MODELS_URL,
288 source_status: SourceStatus::Derived,
289 notes: Some("Opus 4.8 rejects budget thinking — `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API (the SDK fails fast in validate_thinking_config). Adaptive is supported but optional: an effort level can be sent without it via `ThinkingConfig::default_with_effort`."),
290 },
291 ModelCapabilities {
292 provider: "anthropic",
293 model_id: "claude-opus-4-7",
294 context_window: Some(1_000_000),
295 max_output_tokens: Some(128_000),
296 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.")),
297 supports_thinking: true,
298 supports_adaptive_thinking: true,
299 rejects_budget_thinking: true,
300 source_url: ANTHROPIC_MODELS_URL,
301 source_status: SourceStatus::Derived,
302 notes: Some("Opus 4.7 rejects budget thinking — `ThinkingMode::Enabled { budget_tokens }` returns 400 from the Anthropic API (the SDK fails fast in validate_thinking_config). Adaptive is supported but optional: an effort level can be sent without it via `ThinkingConfig::default_with_effort`."),
303 },
304 ModelCapabilities {
305 provider: "anthropic",
306 model_id: "claude-opus-4-6",
307 context_window: Some(1_000_000),
308 max_output_tokens: Some(128_000),
309 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.")),
310 supports_thinking: true,
311 supports_adaptive_thinking: true,
312 rejects_budget_thinking: true,
313 source_url: ANTHROPIC_MODELS_URL,
314 source_status: SourceStatus::Derived,
315 notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
316 },
317 ModelCapabilities {
318 provider: "anthropic",
319 model_id: "claude-sonnet-5",
320 context_window: Some(1_000_000),
321 max_output_tokens: Some(128_000),
322 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.")),
323 supports_thinking: true,
324 supports_adaptive_thinking: true,
325 rejects_budget_thinking: true,
326 source_url: ANTHROPIC_MODELS_URL,
327 source_status: SourceStatus::Official,
328 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)."),
329 },
330 ModelCapabilities {
331 provider: "anthropic",
332 model_id: "claude-sonnet-4-6",
333 context_window: Some(1_000_000),
334 max_output_tokens: Some(64_000),
335 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
336 supports_thinking: true,
337 supports_adaptive_thinking: true,
338 rejects_budget_thinking: true,
339 source_url: ANTHROPIC_MODELS_URL,
340 source_status: SourceStatus::Derived,
341 notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
342 },
343 ModelCapabilities {
344 provider: "anthropic",
345 model_id: "claude-sonnet-4-5-20250929",
346 context_window: Some(200_000),
347 max_output_tokens: Some(64_000),
348 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
349 supports_thinking: true,
350 supports_adaptive_thinking: false,
351 rejects_budget_thinking: false,
352 source_url: ANTHROPIC_MODELS_URL,
353 source_status: SourceStatus::Derived,
354 notes: None,
355 },
356 ModelCapabilities {
357 provider: "anthropic",
358 model_id: "claude-haiku-4-5-20251001",
359 context_window: Some(200_000),
360 max_output_tokens: Some(64_000),
361 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
362 supports_thinking: true,
363 supports_adaptive_thinking: false,
364 rejects_budget_thinking: false,
365 source_url: ANTHROPIC_MODELS_URL,
366 source_status: SourceStatus::Derived,
367 notes: None,
368 },
369 ModelCapabilities {
370 provider: "anthropic",
371 model_id: "claude-sonnet-4-20250514",
372 context_window: Some(200_000),
373 max_output_tokens: Some(64_000),
374 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
375 supports_thinking: true,
376 supports_adaptive_thinking: false,
377 rejects_budget_thinking: false,
378 source_url: ANTHROPIC_MODELS_URL,
379 source_status: SourceStatus::Derived,
380 notes: None,
381 },
382 ModelCapabilities {
383 provider: "anthropic",
384 model_id: "claude-opus-4-20250514",
385 context_window: Some(200_000),
386 max_output_tokens: Some(32_000),
387 pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
388 supports_thinking: true,
389 supports_adaptive_thinking: false,
390 rejects_budget_thinking: false,
391 source_url: ANTHROPIC_MODELS_URL,
392 source_status: SourceStatus::Derived,
393 notes: None,
394 },
395 ModelCapabilities {
396 provider: "anthropic",
397 model_id: "claude-3-5-sonnet-20241022",
398 context_window: Some(200_000),
399 max_output_tokens: Some(8_192),
400 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
401 supports_thinking: true,
402 supports_adaptive_thinking: false,
403 rejects_budget_thinking: false,
404 source_url: ANTHROPIC_MODELS_URL,
405 source_status: SourceStatus::Derived,
406 notes: None,
407 },
408 ModelCapabilities {
409 provider: "anthropic",
410 model_id: "claude-3-5-haiku-20241022",
411 context_window: Some(200_000),
412 max_output_tokens: Some(8_192),
413 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
414 supports_thinking: true,
415 supports_adaptive_thinking: false,
416 rejects_budget_thinking: false,
417 source_url: ANTHROPIC_MODELS_URL,
418 source_status: SourceStatus::Derived,
419 notes: None,
420 },
421 ModelCapabilities {
423 provider: "openai",
424 model_id: "gpt-5.6",
425 context_window: Some(1_050_000),
426 max_output_tokens: Some(128_000),
427 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
428 "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.",
429 )),
430 supports_thinking: true,
431 supports_adaptive_thinking: true,
432 rejects_budget_thinking: false,
433 source_url: OPENAI_GPT56_SOL_URL,
434 source_status: SourceStatus::Official,
435 notes: Some("Official alias for GPT-5.6 Sol."),
436 },
437 ModelCapabilities {
438 provider: "openai",
439 model_id: "gpt-5.6-sol",
440 context_window: Some(1_050_000),
441 max_output_tokens: Some(128_000),
442 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
443 "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.",
444 )),
445 supports_thinking: true,
446 supports_adaptive_thinking: true,
447 rejects_budget_thinking: false,
448 source_url: OPENAI_GPT56_SOL_URL,
449 source_status: SourceStatus::Official,
450 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
451 },
452 ModelCapabilities {
453 provider: "openai",
454 model_id: "gpt-5.6-terra",
455 context_window: Some(1_050_000),
456 max_output_tokens: Some(128_000),
457 pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
458 "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.",
459 )),
460 supports_thinking: true,
461 supports_adaptive_thinking: true,
462 rejects_budget_thinking: false,
463 source_url: OPENAI_GPT56_TERRA_URL,
464 source_status: SourceStatus::Official,
465 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
466 },
467 ModelCapabilities {
468 provider: "openai",
469 model_id: "gpt-5.6-luna",
470 context_window: Some(1_050_000),
471 max_output_tokens: Some(128_000),
472 pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
473 "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.",
474 )),
475 supports_thinking: true,
476 supports_adaptive_thinking: true,
477 rejects_budget_thinking: false,
478 source_url: OPENAI_GPT56_LUNA_URL,
479 source_status: SourceStatus::Official,
480 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
481 },
482 ModelCapabilities {
483 provider: "openai",
484 model_id: "gpt-5.4",
485 context_window: Some(1_050_000),
486 max_output_tokens: Some(128_000),
487 pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
488 supports_thinking: true,
489 supports_adaptive_thinking: false,
490 rejects_budget_thinking: false,
491 source_url: OPENAI_GPT54_URL,
492 source_status: SourceStatus::Official,
493 notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
494 },
495 ModelCapabilities {
496 provider: "openai",
497 model_id: "gpt-5.3-codex",
498 context_window: Some(400_000),
499 max_output_tokens: Some(128_000),
500 pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
501 supports_thinking: true,
502 supports_adaptive_thinking: true,
503 rejects_budget_thinking: false,
504 source_url: OPENAI_GPT53_CODEX_URL,
505 source_status: SourceStatus::Official,
506 notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
507 },
508 ModelCapabilities {
509 provider: "openai",
510 model_id: "gpt-5",
511 context_window: Some(400_000),
512 max_output_tokens: Some(128_000),
513 pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
514 supports_thinking: false,
515 supports_adaptive_thinking: false,
516 rejects_budget_thinking: false,
517 source_url: OPENAI_PRICING_URL,
518 source_status: SourceStatus::Official,
519 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
520 },
521 ModelCapabilities {
522 provider: "openai",
523 model_id: "gpt-5-mini",
524 context_window: Some(400_000),
525 max_output_tokens: Some(128_000),
526 pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
527 supports_thinking: false,
528 supports_adaptive_thinking: false,
529 rejects_budget_thinking: false,
530 source_url: OPENAI_PRICING_URL,
531 source_status: SourceStatus::Official,
532 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
533 },
534 ModelCapabilities {
535 provider: "openai",
536 model_id: "gpt-5-nano",
537 context_window: Some(400_000),
538 max_output_tokens: Some(128_000),
539 pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
540 supports_thinking: false,
541 supports_adaptive_thinking: false,
542 rejects_budget_thinking: false,
543 source_url: OPENAI_PRICING_URL,
544 source_status: SourceStatus::Official,
545 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
546 },
547 ModelCapabilities {
548 provider: "openai",
549 model_id: "gpt-5.2-instant",
550 context_window: Some(400_000),
551 max_output_tokens: Some(128_000),
552 pricing: None,
553 supports_thinking: false,
554 supports_adaptive_thinking: false,
555 rejects_budget_thinking: false,
556 source_url: OPENAI_MODELS_URL,
557 source_status: SourceStatus::Unverified,
558 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
559 },
560 ModelCapabilities {
561 provider: "openai",
562 model_id: "gpt-5.2-thinking",
563 context_window: Some(400_000),
564 max_output_tokens: Some(128_000),
565 pricing: None,
566 supports_thinking: true,
567 supports_adaptive_thinking: false,
568 rejects_budget_thinking: false,
569 source_url: OPENAI_MODELS_URL,
570 source_status: SourceStatus::Unverified,
571 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
572 },
573 ModelCapabilities {
574 provider: "openai",
575 model_id: "gpt-5.2-pro",
576 context_window: Some(400_000),
577 max_output_tokens: Some(128_000),
578 pricing: Some(Pricing::flat(21.0, 168.0)),
579 supports_thinking: true,
580 supports_adaptive_thinking: false,
581 rejects_budget_thinking: false,
582 source_url: OPENAI_GPT52_PRO_URL,
583 source_status: SourceStatus::Official,
584 notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
585 },
586 ModelCapabilities {
587 provider: "openai",
588 model_id: "gpt-5.2-codex",
589 context_window: Some(400_000),
590 max_output_tokens: Some(128_000),
591 pricing: None,
592 supports_thinking: false,
593 supports_adaptive_thinking: false,
594 rejects_budget_thinking: false,
595 source_url: OPENAI_MODELS_URL,
596 source_status: SourceStatus::Unverified,
597 notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
598 },
599 ModelCapabilities {
600 provider: "openai",
601 model_id: "o3",
602 context_window: Some(200_000),
603 max_output_tokens: Some(100_000),
604 pricing: Some(Pricing::flat(1.0, 4.0)),
605 supports_thinking: true,
606 supports_adaptive_thinking: false,
607 rejects_budget_thinking: false,
608 source_url: OPENAI_PRICING_URL,
609 source_status: SourceStatus::Official,
610 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
611 },
612 ModelCapabilities {
613 provider: "openai",
614 model_id: "o3-mini",
615 context_window: Some(200_000),
616 max_output_tokens: Some(100_000),
617 pricing: Some(Pricing::flat(0.55, 2.20)),
618 supports_thinking: true,
619 supports_adaptive_thinking: false,
620 rejects_budget_thinking: false,
621 source_url: OPENAI_PRICING_URL,
622 source_status: SourceStatus::Official,
623 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
624 },
625 ModelCapabilities {
626 provider: "openai",
627 model_id: "o4-mini",
628 context_window: Some(200_000),
629 max_output_tokens: Some(100_000),
630 pricing: Some(Pricing::flat(0.55, 2.20)),
631 supports_thinking: true,
632 supports_adaptive_thinking: false,
633 rejects_budget_thinking: false,
634 source_url: OPENAI_PRICING_URL,
635 source_status: SourceStatus::Official,
636 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
637 },
638 ModelCapabilities {
639 provider: "openai",
640 model_id: "o1",
641 context_window: Some(200_000),
642 max_output_tokens: Some(100_000),
643 pricing: Some(Pricing::flat(7.50, 30.0)),
644 supports_thinking: true,
645 supports_adaptive_thinking: false,
646 rejects_budget_thinking: false,
647 source_url: OPENAI_PRICING_URL,
648 source_status: SourceStatus::Official,
649 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
650 },
651 ModelCapabilities {
652 provider: "openai",
653 model_id: "o1-mini",
654 context_window: Some(200_000),
655 max_output_tokens: Some(100_000),
656 pricing: Some(Pricing::flat(0.55, 2.20)),
657 supports_thinking: true,
658 supports_adaptive_thinking: false,
659 rejects_budget_thinking: false,
660 source_url: OPENAI_PRICING_URL,
661 source_status: SourceStatus::Official,
662 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
663 },
664 ModelCapabilities {
665 provider: "openai",
666 model_id: "gpt-4.1",
667 context_window: Some(1_000_000),
668 max_output_tokens: Some(16_384),
669 pricing: Some(Pricing::flat(1.0, 4.0)),
670 supports_thinking: false,
671 supports_adaptive_thinking: false,
672 rejects_budget_thinking: false,
673 source_url: OPENAI_PRICING_URL,
674 source_status: SourceStatus::Official,
675 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
676 },
677 ModelCapabilities {
678 provider: "openai",
679 model_id: "gpt-4.1-mini",
680 context_window: Some(1_000_000),
681 max_output_tokens: Some(16_384),
682 pricing: Some(Pricing::flat(0.20, 0.80)),
683 supports_thinking: false,
684 supports_adaptive_thinking: false,
685 rejects_budget_thinking: false,
686 source_url: OPENAI_PRICING_URL,
687 source_status: SourceStatus::Official,
688 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
689 },
690 ModelCapabilities {
691 provider: "openai",
692 model_id: "gpt-4.1-nano",
693 context_window: Some(1_000_000),
694 max_output_tokens: Some(16_384),
695 pricing: Some(Pricing::flat(0.05, 0.20)),
696 supports_thinking: false,
697 supports_adaptive_thinking: false,
698 rejects_budget_thinking: false,
699 source_url: OPENAI_PRICING_URL,
700 source_status: SourceStatus::Official,
701 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
702 },
703 ModelCapabilities {
704 provider: "openai",
705 model_id: "gpt-4o",
706 context_window: Some(128_000),
707 max_output_tokens: Some(16_384),
708 pricing: Some(Pricing::flat(1.25, 5.0)),
709 supports_thinking: false,
710 supports_adaptive_thinking: false,
711 rejects_budget_thinking: false,
712 source_url: OPENAI_PRICING_URL,
713 source_status: SourceStatus::Official,
714 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
715 },
716 ModelCapabilities {
717 provider: "openai",
718 model_id: "gpt-4o-mini",
719 context_window: Some(128_000),
720 max_output_tokens: Some(16_384),
721 pricing: Some(Pricing::flat(0.075, 0.30)),
722 supports_thinking: false,
723 supports_adaptive_thinking: false,
724 rejects_budget_thinking: false,
725 source_url: OPENAI_PRICING_URL,
726 source_status: SourceStatus::Official,
727 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
728 },
729 ModelCapabilities {
731 provider: "gemini",
732 model_id: "gemini-3.1-pro-preview",
733 context_window: Some(1_048_576),
734 max_output_tokens: Some(65_536),
735 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.")),
736 supports_thinking: true,
737 supports_adaptive_thinking: false,
738 rejects_budget_thinking: false,
739 source_url: GOOGLE_PRICING_URL,
740 source_status: SourceStatus::Official,
741 notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
742 },
743 ModelCapabilities {
744 provider: "gemini",
745 model_id: "gemini-3.1-pro",
746 context_window: Some(1_048_576),
747 max_output_tokens: Some(65_536),
748 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.")),
749 supports_thinking: true,
750 supports_adaptive_thinking: false,
751 rejects_budget_thinking: false,
752 source_url: GOOGLE_PRICING_URL,
753 source_status: SourceStatus::Derived,
754 notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
755 },
756 ModelCapabilities {
757 provider: "gemini",
758 model_id: "gemini-3.1-flash-lite-preview",
759 context_window: Some(1_048_576),
760 max_output_tokens: Some(65_536),
761 pricing: None,
762 supports_thinking: true,
763 supports_adaptive_thinking: false,
764 rejects_budget_thinking: false,
765 source_url: GOOGLE_MODELS_URL,
766 source_status: SourceStatus::Unverified,
767 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
768 },
769 ModelCapabilities {
770 provider: "gemini",
771 model_id: "gemini-3-flash-preview",
772 context_window: Some(1_048_576),
773 max_output_tokens: Some(65_536),
774 pricing: None,
775 supports_thinking: true,
776 supports_adaptive_thinking: false,
777 rejects_budget_thinking: false,
778 source_url: GOOGLE_MODELS_URL,
779 source_status: SourceStatus::Unverified,
780 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
781 },
782 ModelCapabilities {
783 provider: "gemini",
784 model_id: "gemini-3.0-flash",
785 context_window: Some(1_048_576),
786 max_output_tokens: Some(65_536),
787 pricing: None,
788 supports_thinking: true,
789 supports_adaptive_thinking: false,
790 rejects_budget_thinking: false,
791 source_url: GOOGLE_MODELS_URL,
792 source_status: SourceStatus::Derived,
793 notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
794 },
795 ModelCapabilities {
796 provider: "gemini",
797 model_id: "gemini-3.0-pro",
798 context_window: Some(1_048_576),
799 max_output_tokens: Some(65_536),
800 pricing: None,
801 supports_thinking: true,
802 supports_adaptive_thinking: false,
803 rejects_budget_thinking: false,
804 source_url: GOOGLE_MODELS_URL,
805 source_status: SourceStatus::Unverified,
806 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
807 },
808 ModelCapabilities {
809 provider: "gemini",
810 model_id: "gemini-2.5-flash",
811 context_window: Some(1_000_000),
812 max_output_tokens: Some(65_536),
813 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.")),
814 supports_thinking: true,
815 supports_adaptive_thinking: false,
816 rejects_budget_thinking: false,
817 source_url: GOOGLE_PRICING_URL,
818 source_status: SourceStatus::Official,
819 notes: Some("Official docs state output pricing includes thinking tokens."),
820 },
821 ModelCapabilities {
822 provider: "gemini",
823 model_id: "gemini-2.5-pro",
824 context_window: Some(1_000_000),
825 max_output_tokens: Some(65_536),
826 pricing: None,
827 supports_thinking: true,
828 supports_adaptive_thinking: false,
829 rejects_budget_thinking: false,
830 source_url: GOOGLE_MODELS_URL,
831 source_status: SourceStatus::Unverified,
832 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
833 },
834 ModelCapabilities {
835 provider: "gemini",
836 model_id: "gemini-2.0-flash",
837 context_window: Some(1_000_000),
838 max_output_tokens: Some(8_192),
839 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.")),
840 supports_thinking: false,
841 supports_adaptive_thinking: false,
842 rejects_budget_thinking: false,
843 source_url: GOOGLE_PRICING_URL,
844 source_status: SourceStatus::Official,
845 notes: None,
846 },
847 ModelCapabilities {
848 provider: "gemini",
849 model_id: "gemini-2.0-flash-lite",
850 context_window: Some(1_000_000),
851 max_output_tokens: Some(8_192),
852 pricing: Some(Pricing::flat(0.075, 0.30)),
853 supports_thinking: false,
854 supports_adaptive_thinking: false,
855 rejects_budget_thinking: false,
856 source_url: GOOGLE_PRICING_URL,
857 source_status: SourceStatus::Official,
858 notes: None,
859 },
860 ModelCapabilities {
864 provider: "openai",
865 model_id: "z-ai/glm-5.1",
866 context_window: Some(202_752),
867 max_output_tokens: Some(131_072),
868 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.")),
869 supports_thinking: true,
870 supports_adaptive_thinking: false,
871 rejects_budget_thinking: false,
872 source_url: OPENROUTER_GLM51_URL,
873 source_status: SourceStatus::Derived,
874 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."),
875 },
876 ModelCapabilities {
877 provider: "openai",
878 model_id: "glm-5",
879 context_window: Some(200_000),
880 max_output_tokens: Some(131_072),
881 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).")),
882 supports_thinking: true,
883 supports_adaptive_thinking: false,
884 rejects_budget_thinking: false,
885 source_url: ZAI_GLM5_PRICING_URL,
886 source_status: SourceStatus::Derived,
887 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."),
888 },
889 ModelCapabilities {
890 provider: "openai",
891 model_id: "moonshotai/kimi-k2.6",
892 context_window: Some(262_144),
893 max_output_tokens: Some(65_536),
894 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.")),
895 supports_thinking: false,
896 supports_adaptive_thinking: false,
897 rejects_budget_thinking: false,
898 source_url: OPENROUTER_KIMI_K26_URL,
899 source_status: SourceStatus::Derived,
900 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."),
901 },
902 ModelCapabilities {
903 provider: "openai",
904 model_id: "moonshotai/kimi-k2.5",
905 context_window: Some(262_144),
906 max_output_tokens: Some(32_768),
907 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.")),
908 supports_thinking: false,
909 supports_adaptive_thinking: false,
910 rejects_budget_thinking: false,
911 source_url: OPENROUTER_KIMI_K25_URL,
912 source_status: SourceStatus::Derived,
913 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."),
914 },
915 ModelCapabilities {
916 provider: "openai",
917 model_id: "kimi-k2.5",
918 context_window: Some(262_144),
919 max_output_tokens: Some(32_768),
920 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.")),
921 supports_thinking: false,
922 supports_adaptive_thinking: false,
923 rejects_budget_thinking: false,
924 source_url: KIMI_K25_AA_URL,
925 source_status: SourceStatus::Unverified,
926 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."),
927 },
928 ModelCapabilities {
929 provider: "openai",
930 model_id: "kimi-k2-thinking",
931 context_window: Some(262_144),
932 max_output_tokens: Some(131_072),
933 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.")),
934 supports_thinking: true,
935 supports_adaptive_thinking: false,
936 rejects_budget_thinking: false,
937 source_url: OPENROUTER_KIMI_K2_THINKING_URL,
938 source_status: SourceStatus::Unverified,
939 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."),
940 },
941 ModelCapabilities {
942 provider: "openai",
943 model_id: "deepseek/deepseek-v4-pro",
944 context_window: Some(1_048_576),
945 max_output_tokens: Some(384_000),
946 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.")),
947 supports_thinking: true,
948 supports_adaptive_thinking: false,
949 rejects_budget_thinking: false,
950 source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
951 source_status: SourceStatus::Derived,
952 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."),
953 },
954 ModelCapabilities {
955 provider: "openai",
956 model_id: "deepseek-v4-pro",
957 context_window: Some(1_048_576),
958 max_output_tokens: Some(384_000),
959 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.")),
960 supports_thinking: true,
961 supports_adaptive_thinking: false,
962 rejects_budget_thinking: false,
963 source_url: DEEPSEEK_PRICING_URL,
964 source_status: SourceStatus::Derived,
965 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."),
966 },
967 ModelCapabilities {
968 provider: "openai",
969 model_id: "deepseek/deepseek-v4-flash",
970 context_window: Some(1_048_576),
971 max_output_tokens: Some(384_000),
972 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.")),
973 supports_thinking: true,
974 supports_adaptive_thinking: false,
975 rejects_budget_thinking: false,
976 source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
977 source_status: SourceStatus::Derived,
978 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."),
979 },
980 ModelCapabilities {
981 provider: "openai",
982 model_id: "deepseek-v4-flash",
983 context_window: Some(1_048_576),
984 max_output_tokens: Some(384_000),
985 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.")),
986 supports_thinking: true,
987 supports_adaptive_thinking: false,
988 rejects_budget_thinking: false,
989 source_url: DEEPSEEK_PRICING_URL,
990 source_status: SourceStatus::Derived,
991 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."),
992 },
993 ModelCapabilities {
994 provider: "openai",
995 model_id: "MiniMax-M2.5",
996 context_window: Some(204_800),
997 max_output_tokens: Some(131_072),
998 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).")),
999 supports_thinking: true,
1000 supports_adaptive_thinking: false,
1001 rejects_budget_thinking: false,
1002 source_url: MINIMAX_PRICING_URL,
1003 source_status: SourceStatus::Derived,
1004 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."),
1005 },
1006 ModelCapabilities {
1007 provider: "openai",
1008 model_id: "minimax/minimax-m2.5",
1009 context_window: Some(204_800),
1010 max_output_tokens: Some(131_072),
1011 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).")),
1012 supports_thinking: true,
1013 supports_adaptive_thinking: false,
1014 rejects_budget_thinking: false,
1015 source_url: OPENROUTER_MINIMAX_M25_URL,
1016 source_status: SourceStatus::Derived,
1017 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."),
1018 },
1019];
1020
1021#[must_use]
1022pub fn get_model_capabilities(
1023 provider: &str,
1024 model_id: &str,
1025) -> Option<&'static ModelCapabilities> {
1026 MODEL_CAPABILITIES.iter().find(|caps| {
1027 caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
1028 })
1029}
1030
1031#[must_use]
1032pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
1033 get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
1034}
1035
1036#[must_use]
1037pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
1038 MODEL_CAPABILITIES
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044
1045 #[test]
1050 fn output_bills_at_the_dearer_reasoning_rate() -> anyhow::Result<()> {
1051 use anyhow::Context;
1052 let pricing = Pricing::flat(0.7, 2.8).with_reasoning(8.4);
1053 let usage = Usage {
1054 served_speed: None,
1055 input_tokens: 1_000_000,
1056 output_tokens: 1_000_000,
1057 cached_input_tokens: 0,
1058 cache_creation_input_tokens: 0,
1059 };
1060 let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1062 assert!((cost - 9.1).abs() < 1e-9, "unexpected cost: {cost}");
1063 Ok(())
1064 }
1065
1066 #[test]
1070 fn output_keeps_the_dearer_output_rate() -> anyhow::Result<()> {
1071 use anyhow::Context;
1072 let pricing = Pricing::flat(1.0, 8.0).with_reasoning(3.0);
1073 let usage = Usage {
1074 served_speed: None,
1075 input_tokens: 0,
1076 output_tokens: 1_000_000,
1077 cached_input_tokens: 0,
1078 cache_creation_input_tokens: 0,
1079 };
1080 let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1082 assert!((cost - 8.0).abs() < 1e-9, "unexpected cost: {cost}");
1083 Ok(())
1084 }
1085
1086 #[test]
1087 fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
1088 use anyhow::Context;
1089
1090 let caps = get_model_capabilities("anthropic", "claude-fable-5")
1091 .context("claude-fable-5 capabilities missing")?;
1092 assert_eq!(caps.context_window, Some(1_000_000));
1093 assert_eq!(caps.max_output_tokens, Some(128_000));
1094 assert!(caps.supports_thinking);
1095 assert!(caps.supports_adaptive_thinking);
1096 assert_eq!(caps.source_status, SourceStatus::Official);
1097 let pricing = caps.pricing.context("pricing missing")?;
1098 let input = pricing.input.context("input price missing")?;
1099 let output = pricing.output.context("output price missing")?;
1100 assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
1101 assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
1102 Ok(())
1103 }
1104
1105 #[test]
1106 fn test_lookup_anthropic_opus_5() -> anyhow::Result<()> {
1107 use anyhow::Context;
1108
1109 let caps = get_model_capabilities("anthropic", "claude-opus-5")
1110 .context("claude-opus-5 capabilities missing")?;
1111 assert_eq!(caps.context_window, Some(1_000_000));
1112 assert_eq!(caps.max_output_tokens, Some(128_000));
1113 assert!(caps.supports_thinking);
1114 assert!(caps.supports_adaptive_thinking);
1116 assert_eq!(caps.source_status, SourceStatus::Official);
1117 let pricing = caps.pricing.context("pricing missing")?;
1118 let input = pricing.input.context("input price missing")?;
1119 let output = pricing.output.context("output price missing")?;
1120 assert!((input.usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1121 assert!((output.usd_per_million_tokens - 25.0).abs() < f64::EPSILON);
1122 Ok(())
1123 }
1124
1125 #[test]
1126 fn test_lookup_anthropic_opus_48() {
1127 let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
1128 assert_eq!(caps.context_window, Some(1_000_000));
1129 assert_eq!(caps.max_output_tokens, Some(128_000));
1130 assert!(caps.supports_thinking);
1131 assert!(caps.supports_adaptive_thinking);
1132 }
1133
1134 #[test]
1135 fn test_lookup_anthropic_opus_46() {
1136 let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
1137 assert_eq!(caps.context_window, Some(1_000_000));
1138 assert_eq!(caps.max_output_tokens, Some(128_000));
1139 assert!(caps.supports_adaptive_thinking);
1140 }
1141
1142 #[test]
1143 fn test_lookup_anthropic_sonnet_5() {
1144 let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
1145 assert_eq!(caps.context_window, Some(1_000_000));
1146 assert_eq!(caps.max_output_tokens, Some(128_000));
1147 assert!(caps.supports_thinking);
1148 assert!(caps.supports_adaptive_thinking);
1150 }
1151
1152 #[test]
1153 fn test_lookup_anthropic_sonnet_46() {
1154 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
1155 assert_eq!(caps.context_window, Some(1_000_000));
1156 assert_eq!(caps.max_output_tokens, Some(64_000));
1157 assert!(caps.supports_adaptive_thinking);
1158 }
1159
1160 #[test]
1161 fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
1162 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
1163 assert!(!caps.supports_adaptive_thinking);
1164 }
1165
1166 #[test]
1167 fn budget_rejecting_anthropic_models_carry_the_flag() {
1168 for model in [
1169 "claude-fable-5",
1170 "claude-opus-5",
1171 "claude-opus-4-8",
1172 "claude-opus-4-7",
1173 "claude-opus-4-6",
1174 "claude-sonnet-5",
1175 "claude-sonnet-4-6",
1176 ] {
1177 let caps = get_model_capabilities("anthropic", model).unwrap();
1178 assert!(
1179 caps.rejects_budget_thinking,
1180 "{model} rejects budget thinking and must carry the flag"
1181 );
1182 }
1183 }
1184
1185 #[test]
1186 fn rejects_budget_thinking_implies_adaptive_is_available() {
1187 for caps in MODEL_CAPABILITIES {
1188 if caps.rejects_budget_thinking {
1189 assert!(
1190 caps.supports_adaptive_thinking,
1191 "{}/{} rejects budget thinking but does not support adaptive — \
1192 callers would have no thinking-object shape to promote to",
1193 caps.provider, caps.model_id,
1194 );
1195 }
1196 }
1197 }
1198
1199 #[test]
1200 fn budget_capable_models_do_not_carry_the_flag() {
1201 for (provider, model) in [
1202 ("anthropic", "claude-sonnet-4-5-20250929"),
1203 ("anthropic", "claude-haiku-4-5-20251001"),
1204 ("openai", "gpt-5.6-sol"),
1205 ] {
1206 let caps = get_model_capabilities(provider, model).unwrap();
1207 assert!(
1208 !caps.rejects_budget_thinking,
1209 "{provider}/{model} accepts budget-style thinking config"
1210 );
1211 }
1212 }
1213
1214 #[test]
1215 fn test_lookup_openai_pricing() {
1216 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1217 let pricing = caps.pricing.unwrap();
1218 assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
1219 assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1220 }
1221
1222 #[test]
1223 fn test_lookup_openai_gpt54() {
1224 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1225 assert_eq!(caps.context_window, Some(1_050_000));
1226 assert_eq!(caps.max_output_tokens, Some(128_000));
1227 assert!(caps.supports_thinking);
1228 assert_eq!(caps.source_status, SourceStatus::Official);
1229 }
1230
1231 #[test]
1232 fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
1233 use anyhow::Context;
1234
1235 let caps = get_model_capabilities("openai", "gpt-5.2-pro")
1236 .context("gpt-5.2-pro capabilities missing")?;
1237 assert_eq!(caps.context_window, Some(400_000));
1238 assert_eq!(caps.max_output_tokens, Some(128_000));
1239 assert!(caps.supports_thinking);
1240 assert_eq!(caps.source_status, SourceStatus::Official);
1241 let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
1242 let input = pricing.input.context("input price missing")?;
1243 let output = pricing.output.context("output price missing")?;
1244 assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
1245 assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
1246 Ok(())
1247 }
1248
1249 #[test]
1250 fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
1251 use anyhow::Context as _;
1252
1253 for (model_id, input, cached_input, output, cache_write_note) in [
1254 ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
1255 ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
1256 ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
1257 ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
1258 ] {
1259 let caps = get_model_capabilities("openai", model_id)
1260 .with_context(|| format!("{model_id} capabilities missing"))?;
1261 assert_eq!(caps.context_window, Some(1_050_000));
1262 assert_eq!(caps.max_output_tokens, Some(128_000));
1263 assert!(caps.supports_thinking);
1264 assert!(caps.supports_adaptive_thinking);
1265 assert_eq!(caps.source_status, SourceStatus::Official);
1266 let pricing = caps
1267 .pricing
1268 .with_context(|| format!("{model_id} pricing missing"))?;
1269 assert_eq!(pricing.input, Some(PricePoint::new(input)));
1270 assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
1271 assert_eq!(pricing.output, Some(PricePoint::new(output)));
1272 assert!(pricing.notes.is_some_and(|notes| {
1273 notes.contains(cache_write_note) && notes.contains("more than 272K")
1274 }));
1275 }
1276
1277 Ok(())
1278 }
1279
1280 #[test]
1281 fn test_lookup_openai_gpt53_codex() {
1282 let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
1283 assert_eq!(caps.context_window, Some(400_000));
1284 assert_eq!(caps.max_output_tokens, Some(128_000));
1285 assert!(caps.supports_adaptive_thinking);
1286 assert!(caps.supports_thinking);
1287 assert_eq!(caps.source_status, SourceStatus::Official);
1288 }
1289
1290 #[test]
1291 fn test_lookup_gemini_preview_models() {
1292 let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1293 assert_eq!(flash.context_window, Some(1_048_576));
1294 assert!(flash.supports_thinking);
1295
1296 let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1297 assert_eq!(pro.max_output_tokens, Some(65_536));
1298 assert!(pro.supports_thinking);
1299 }
1300
1301 #[test]
1302 fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1303 let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1305 assert!(deepseek.supports_thinking);
1306 assert_eq!(deepseek.max_output_tokens, Some(384_000));
1307 let pricing = deepseek.pricing.unwrap();
1308 assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1309 assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1310
1311 let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1313 assert!(glm.supports_thinking);
1314 assert_eq!(glm.max_output_tokens, Some(131_072));
1315 let glm_pricing = glm.pricing.unwrap();
1316 assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1317 assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1318
1319 let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1321 assert!(kimi_thinking.supports_thinking);
1322 assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1323 assert!(
1324 kimi_thinking
1325 .pricing
1326 .unwrap()
1327 .output
1328 .unwrap()
1329 .usd_per_million_tokens
1330 > 0.0
1331 );
1332 }
1333
1334 #[test]
1335 fn test_lookup_open_non_reasoning_kimi_models() {
1336 let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1338 assert!(!k26.supports_thinking);
1339 assert_eq!(k26.max_output_tokens, Some(65_536));
1340 assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1341
1342 let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1343 assert!(!k25_native.supports_thinking);
1344 assert_eq!(k25_native.max_output_tokens, Some(32_768));
1345 }
1346
1347 #[test]
1348 fn test_lookup_all_open_models_resolve() {
1349 for model_id in [
1352 "z-ai/glm-5.1",
1353 "glm-5",
1354 "moonshotai/kimi-k2.6",
1355 "moonshotai/kimi-k2.5",
1356 "kimi-k2.5",
1357 "kimi-k2-thinking",
1358 "deepseek/deepseek-v4-pro",
1359 "deepseek-v4-pro",
1360 "deepseek/deepseek-v4-flash",
1361 "deepseek-v4-flash",
1362 "MiniMax-M2.5",
1363 "minimax/minimax-m2.5",
1364 ] {
1365 let caps = get_model_capabilities("openai", model_id)
1366 .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1367 assert!(
1368 caps.pricing.is_some(),
1369 "pricing should be populated for {model_id}"
1370 );
1371 assert!(
1372 caps.max_output_tokens.is_some_and(|m| m > 0),
1373 "max_output_tokens should be non-zero for {model_id}"
1374 );
1375 assert!(
1376 caps.context_window.is_some_and(|c| c > 0),
1377 "context_window should be non-zero for {model_id}"
1378 );
1379 }
1380 }
1381
1382 #[test]
1383 fn test_lookup_minimax_native_pricing() {
1384 let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1385 assert!(native.supports_thinking);
1386 let pricing = native.pricing.unwrap();
1387 assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1388 assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1389 assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1392 }
1393
1394 #[test]
1395 fn test_estimate_cost_usd() {
1396 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1397 let cost = caps
1398 .estimate_cost_usd(&Usage {
1399 served_speed: None,
1400 input_tokens: 2_000,
1401 output_tokens: 1_000,
1402 cached_input_tokens: 0,
1403 cache_creation_input_tokens: 0,
1404 })
1405 .unwrap();
1406 assert!((cost - 0.0075).abs() < f64::EPSILON);
1407 }
1408
1409 #[test]
1410 fn test_estimate_cost_usd_with_cached_input() {
1411 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1412 let cost = caps
1413 .estimate_cost_usd(&Usage {
1414 served_speed: None,
1415 input_tokens: 2_000,
1416 output_tokens: 1_000,
1417 cached_input_tokens: 1_000,
1418 cache_creation_input_tokens: 0,
1419 })
1420 .unwrap();
1421 assert!((cost - 0.01775).abs() < f64::EPSILON);
1422 }
1423}