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-5",
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 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.")),
262 supports_thinking: true,
263 supports_adaptive_thinking: true,
264 source_url: ANTHROPIC_MODELS_URL,
265 source_status: SourceStatus::Official,
266 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`."),
267 },
268 ModelCapabilities {
269 provider: "anthropic",
270 model_id: "claude-opus-4-8",
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.8 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.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`."),
279 },
280 ModelCapabilities {
281 provider: "anthropic",
282 model_id: "claude-opus-4-7",
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.7 pricing matches the Opus 4.6 tier ($5/$25 per 1M); 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("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`."),
291 },
292 ModelCapabilities {
293 provider: "anthropic",
294 model_id: "claude-opus-4-6",
295 context_window: Some(1_000_000),
296 max_output_tokens: Some(128_000),
297 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.")),
298 supports_thinking: true,
299 supports_adaptive_thinking: true,
300 source_url: ANTHROPIC_MODELS_URL,
301 source_status: SourceStatus::Derived,
302 notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
303 },
304 ModelCapabilities {
305 provider: "anthropic",
306 model_id: "claude-sonnet-5",
307 context_window: Some(1_000_000),
308 max_output_tokens: Some(128_000),
309 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.")),
310 supports_thinking: true,
311 supports_adaptive_thinking: true,
312 source_url: ANTHROPIC_MODELS_URL,
313 source_status: SourceStatus::Official,
314 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)."),
315 },
316 ModelCapabilities {
317 provider: "anthropic",
318 model_id: "claude-sonnet-4-6",
319 context_window: Some(1_000_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: true,
324 source_url: ANTHROPIC_MODELS_URL,
325 source_status: SourceStatus::Derived,
326 notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
327 },
328 ModelCapabilities {
329 provider: "anthropic",
330 model_id: "claude-sonnet-4-5-20250929",
331 context_window: Some(200_000),
332 max_output_tokens: Some(64_000),
333 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 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-haiku-4-5-20251001",
343 context_window: Some(200_000),
344 max_output_tokens: Some(64_000),
345 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku 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-sonnet-4-20250514",
355 context_window: Some(200_000),
356 max_output_tokens: Some(64_000),
357 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 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-opus-4-20250514",
367 context_window: Some(200_000),
368 max_output_tokens: Some(32_000),
369 pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus 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-sonnet-20241022",
379 context_window: Some(200_000),
380 max_output_tokens: Some(8_192),
381 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet 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 {
389 provider: "anthropic",
390 model_id: "claude-3-5-haiku-20241022",
391 context_window: Some(200_000),
392 max_output_tokens: Some(8_192),
393 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
394 supports_thinking: true,
395 supports_adaptive_thinking: false,
396 source_url: ANTHROPIC_MODELS_URL,
397 source_status: SourceStatus::Derived,
398 notes: None,
399 },
400 ModelCapabilities {
402 provider: "openai",
403 model_id: "gpt-5.6",
404 context_window: Some(1_050_000),
405 max_output_tokens: Some(128_000),
406 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
407 "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.",
408 )),
409 supports_thinking: true,
410 supports_adaptive_thinking: true,
411 source_url: OPENAI_GPT56_SOL_URL,
412 source_status: SourceStatus::Official,
413 notes: Some("Official alias for GPT-5.6 Sol."),
414 },
415 ModelCapabilities {
416 provider: "openai",
417 model_id: "gpt-5.6-sol",
418 context_window: Some(1_050_000),
419 max_output_tokens: Some(128_000),
420 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
421 "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.",
422 )),
423 supports_thinking: true,
424 supports_adaptive_thinking: true,
425 source_url: OPENAI_GPT56_SOL_URL,
426 source_status: SourceStatus::Official,
427 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
428 },
429 ModelCapabilities {
430 provider: "openai",
431 model_id: "gpt-5.6-terra",
432 context_window: Some(1_050_000),
433 max_output_tokens: Some(128_000),
434 pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
435 "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.",
436 )),
437 supports_thinking: true,
438 supports_adaptive_thinking: true,
439 source_url: OPENAI_GPT56_TERRA_URL,
440 source_status: SourceStatus::Official,
441 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
442 },
443 ModelCapabilities {
444 provider: "openai",
445 model_id: "gpt-5.6-luna",
446 context_window: Some(1_050_000),
447 max_output_tokens: Some(128_000),
448 pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
449 "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.",
450 )),
451 supports_thinking: true,
452 supports_adaptive_thinking: true,
453 source_url: OPENAI_GPT56_LUNA_URL,
454 source_status: SourceStatus::Official,
455 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
456 },
457 ModelCapabilities {
458 provider: "openai",
459 model_id: "gpt-5.4",
460 context_window: Some(1_050_000),
461 max_output_tokens: Some(128_000),
462 pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
463 supports_thinking: true,
464 supports_adaptive_thinking: false,
465 source_url: OPENAI_GPT54_URL,
466 source_status: SourceStatus::Official,
467 notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
468 },
469 ModelCapabilities {
470 provider: "openai",
471 model_id: "gpt-5.3-codex",
472 context_window: Some(400_000),
473 max_output_tokens: Some(128_000),
474 pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
475 supports_thinking: true,
476 supports_adaptive_thinking: true,
477 source_url: OPENAI_GPT53_CODEX_URL,
478 source_status: SourceStatus::Official,
479 notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
480 },
481 ModelCapabilities {
482 provider: "openai",
483 model_id: "gpt-5",
484 context_window: Some(400_000),
485 max_output_tokens: Some(128_000),
486 pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
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-mini",
496 context_window: Some(400_000),
497 max_output_tokens: Some(128_000),
498 pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
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-nano",
508 context_window: Some(400_000),
509 max_output_tokens: Some(128_000),
510 pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
511 supports_thinking: false,
512 supports_adaptive_thinking: false,
513 source_url: OPENAI_PRICING_URL,
514 source_status: SourceStatus::Official,
515 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
516 },
517 ModelCapabilities {
518 provider: "openai",
519 model_id: "gpt-5.2-instant",
520 context_window: Some(400_000),
521 max_output_tokens: Some(128_000),
522 pricing: None,
523 supports_thinking: false,
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-thinking",
532 context_window: Some(400_000),
533 max_output_tokens: Some(128_000),
534 pricing: None,
535 supports_thinking: true,
536 supports_adaptive_thinking: false,
537 source_url: OPENAI_MODELS_URL,
538 source_status: SourceStatus::Unverified,
539 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
540 },
541 ModelCapabilities {
542 provider: "openai",
543 model_id: "gpt-5.2-pro",
544 context_window: Some(400_000),
545 max_output_tokens: Some(128_000),
546 pricing: Some(Pricing::flat(21.0, 168.0)),
547 supports_thinking: true,
548 supports_adaptive_thinking: false,
549 source_url: OPENAI_GPT52_PRO_URL,
550 source_status: SourceStatus::Official,
551 notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
552 },
553 ModelCapabilities {
554 provider: "openai",
555 model_id: "gpt-5.2-codex",
556 context_window: Some(400_000),
557 max_output_tokens: Some(128_000),
558 pricing: None,
559 supports_thinking: false,
560 supports_adaptive_thinking: false,
561 source_url: OPENAI_MODELS_URL,
562 source_status: SourceStatus::Unverified,
563 notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
564 },
565 ModelCapabilities {
566 provider: "openai",
567 model_id: "o3",
568 context_window: Some(200_000),
569 max_output_tokens: Some(100_000),
570 pricing: Some(Pricing::flat(1.0, 4.0)),
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: "o3-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: "o4-mini",
592 context_window: Some(200_000),
593 max_output_tokens: Some(100_000),
594 pricing: Some(Pricing::flat(0.55, 2.20)),
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",
604 context_window: Some(200_000),
605 max_output_tokens: Some(100_000),
606 pricing: Some(Pricing::flat(7.50, 30.0)),
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: "o1-mini",
616 context_window: Some(200_000),
617 max_output_tokens: Some(100_000),
618 pricing: Some(Pricing::flat(0.55, 2.20)),
619 supports_thinking: true,
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/max output still need clean extraction from models docs."),
624 },
625 ModelCapabilities {
626 provider: "openai",
627 model_id: "gpt-4.1",
628 context_window: Some(1_000_000),
629 max_output_tokens: Some(16_384),
630 pricing: Some(Pricing::flat(1.0, 4.0)),
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-mini",
640 context_window: Some(1_000_000),
641 max_output_tokens: Some(16_384),
642 pricing: Some(Pricing::flat(0.20, 0.80)),
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-4.1-nano",
652 context_window: Some(1_000_000),
653 max_output_tokens: Some(16_384),
654 pricing: Some(Pricing::flat(0.05, 0.20)),
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 window from model family docs/notes."),
660 },
661 ModelCapabilities {
662 provider: "openai",
663 model_id: "gpt-4o",
664 context_window: Some(128_000),
665 max_output_tokens: Some(16_384),
666 pricing: Some(Pricing::flat(1.25, 5.0)),
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 {
674 provider: "openai",
675 model_id: "gpt-4o-mini",
676 context_window: Some(128_000),
677 max_output_tokens: Some(16_384),
678 pricing: Some(Pricing::flat(0.075, 0.30)),
679 supports_thinking: false,
680 supports_adaptive_thinking: false,
681 source_url: OPENAI_PRICING_URL,
682 source_status: SourceStatus::Official,
683 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
684 },
685 ModelCapabilities {
687 provider: "gemini",
688 model_id: "gemini-3.1-pro-preview",
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("Official pricing for prompts <= 200K tokens. 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::Official,
696 notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
697 },
698 ModelCapabilities {
699 provider: "gemini",
700 model_id: "gemini-3.1-pro",
701 context_window: Some(1_048_576),
702 max_output_tokens: Some(65_536),
703 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.")),
704 supports_thinking: true,
705 supports_adaptive_thinking: false,
706 source_url: GOOGLE_PRICING_URL,
707 source_status: SourceStatus::Derived,
708 notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
709 },
710 ModelCapabilities {
711 provider: "gemini",
712 model_id: "gemini-3.1-flash-lite-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-flash-preview",
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::Unverified,
732 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
733 },
734 ModelCapabilities {
735 provider: "gemini",
736 model_id: "gemini-3.0-flash",
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::Derived,
744 notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
745 },
746 ModelCapabilities {
747 provider: "gemini",
748 model_id: "gemini-3.0-pro",
749 context_window: Some(1_048_576),
750 max_output_tokens: Some(65_536),
751 pricing: None,
752 supports_thinking: true,
753 supports_adaptive_thinking: false,
754 source_url: GOOGLE_MODELS_URL,
755 source_status: SourceStatus::Unverified,
756 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
757 },
758 ModelCapabilities {
759 provider: "gemini",
760 model_id: "gemini-2.5-flash",
761 context_window: Some(1_000_000),
762 max_output_tokens: Some(65_536),
763 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.")),
764 supports_thinking: true,
765 supports_adaptive_thinking: false,
766 source_url: GOOGLE_PRICING_URL,
767 source_status: SourceStatus::Official,
768 notes: Some("Official docs state output pricing includes thinking tokens."),
769 },
770 ModelCapabilities {
771 provider: "gemini",
772 model_id: "gemini-2.5-pro",
773 context_window: Some(1_000_000),
774 max_output_tokens: Some(65_536),
775 pricing: None,
776 supports_thinking: true,
777 supports_adaptive_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-2.0-flash",
785 context_window: Some(1_000_000),
786 max_output_tokens: Some(8_192),
787 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.")),
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 {
795 provider: "gemini",
796 model_id: "gemini-2.0-flash-lite",
797 context_window: Some(1_000_000),
798 max_output_tokens: Some(8_192),
799 pricing: Some(Pricing::flat(0.075, 0.30)),
800 supports_thinking: false,
801 supports_adaptive_thinking: false,
802 source_url: GOOGLE_PRICING_URL,
803 source_status: SourceStatus::Official,
804 notes: None,
805 },
806 ModelCapabilities {
810 provider: "openai",
811 model_id: "z-ai/glm-5.1",
812 context_window: Some(202_752),
813 max_output_tokens: Some(131_072),
814 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.")),
815 supports_thinking: true,
816 supports_adaptive_thinking: false,
817 source_url: OPENROUTER_GLM51_URL,
818 source_status: SourceStatus::Derived,
819 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."),
820 },
821 ModelCapabilities {
822 provider: "openai",
823 model_id: "glm-5",
824 context_window: Some(200_000),
825 max_output_tokens: Some(131_072),
826 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).")),
827 supports_thinking: true,
828 supports_adaptive_thinking: false,
829 source_url: ZAI_GLM5_PRICING_URL,
830 source_status: SourceStatus::Derived,
831 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."),
832 },
833 ModelCapabilities {
834 provider: "openai",
835 model_id: "moonshotai/kimi-k2.6",
836 context_window: Some(262_144),
837 max_output_tokens: Some(65_536),
838 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.")),
839 supports_thinking: false,
840 supports_adaptive_thinking: false,
841 source_url: OPENROUTER_KIMI_K26_URL,
842 source_status: SourceStatus::Derived,
843 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."),
844 },
845 ModelCapabilities {
846 provider: "openai",
847 model_id: "moonshotai/kimi-k2.5",
848 context_window: Some(262_144),
849 max_output_tokens: Some(32_768),
850 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.")),
851 supports_thinking: false,
852 supports_adaptive_thinking: false,
853 source_url: OPENROUTER_KIMI_K25_URL,
854 source_status: SourceStatus::Derived,
855 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."),
856 },
857 ModelCapabilities {
858 provider: "openai",
859 model_id: "kimi-k2.5",
860 context_window: Some(262_144),
861 max_output_tokens: Some(32_768),
862 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.")),
863 supports_thinking: false,
864 supports_adaptive_thinking: false,
865 source_url: KIMI_K25_AA_URL,
866 source_status: SourceStatus::Unverified,
867 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."),
868 },
869 ModelCapabilities {
870 provider: "openai",
871 model_id: "kimi-k2-thinking",
872 context_window: Some(262_144),
873 max_output_tokens: Some(131_072),
874 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.")),
875 supports_thinking: true,
876 supports_adaptive_thinking: false,
877 source_url: OPENROUTER_KIMI_K2_THINKING_URL,
878 source_status: SourceStatus::Unverified,
879 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."),
880 },
881 ModelCapabilities {
882 provider: "openai",
883 model_id: "deepseek/deepseek-v4-pro",
884 context_window: Some(1_048_576),
885 max_output_tokens: Some(384_000),
886 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.")),
887 supports_thinking: true,
888 supports_adaptive_thinking: false,
889 source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
890 source_status: SourceStatus::Derived,
891 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."),
892 },
893 ModelCapabilities {
894 provider: "openai",
895 model_id: "deepseek-v4-pro",
896 context_window: Some(1_048_576),
897 max_output_tokens: Some(384_000),
898 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.")),
899 supports_thinking: true,
900 supports_adaptive_thinking: false,
901 source_url: DEEPSEEK_PRICING_URL,
902 source_status: SourceStatus::Derived,
903 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."),
904 },
905 ModelCapabilities {
906 provider: "openai",
907 model_id: "deepseek/deepseek-v4-flash",
908 context_window: Some(1_048_576),
909 max_output_tokens: Some(384_000),
910 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.")),
911 supports_thinking: true,
912 supports_adaptive_thinking: false,
913 source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
914 source_status: SourceStatus::Derived,
915 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."),
916 },
917 ModelCapabilities {
918 provider: "openai",
919 model_id: "deepseek-v4-flash",
920 context_window: Some(1_048_576),
921 max_output_tokens: Some(384_000),
922 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.")),
923 supports_thinking: true,
924 supports_adaptive_thinking: false,
925 source_url: DEEPSEEK_PRICING_URL,
926 source_status: SourceStatus::Derived,
927 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."),
928 },
929 ModelCapabilities {
930 provider: "openai",
931 model_id: "MiniMax-M2.5",
932 context_window: Some(204_800),
933 max_output_tokens: Some(131_072),
934 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).")),
935 supports_thinking: true,
936 supports_adaptive_thinking: false,
937 source_url: MINIMAX_PRICING_URL,
938 source_status: SourceStatus::Derived,
939 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."),
940 },
941 ModelCapabilities {
942 provider: "openai",
943 model_id: "minimax/minimax-m2.5",
944 context_window: Some(204_800),
945 max_output_tokens: Some(131_072),
946 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).")),
947 supports_thinking: true,
948 supports_adaptive_thinking: false,
949 source_url: OPENROUTER_MINIMAX_M25_URL,
950 source_status: SourceStatus::Derived,
951 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."),
952 },
953];
954
955#[must_use]
956pub fn get_model_capabilities(
957 provider: &str,
958 model_id: &str,
959) -> Option<&'static ModelCapabilities> {
960 MODEL_CAPABILITIES.iter().find(|caps| {
961 caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
962 })
963}
964
965#[must_use]
966pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
967 get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
968}
969
970#[must_use]
971pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
972 MODEL_CAPABILITIES
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978
979 #[test]
984 fn output_bills_at_the_dearer_reasoning_rate() -> anyhow::Result<()> {
985 use anyhow::Context;
986 let pricing = Pricing::flat(0.7, 2.8).with_reasoning(8.4);
987 let usage = Usage {
988 served_speed: None,
989 input_tokens: 1_000_000,
990 output_tokens: 1_000_000,
991 cached_input_tokens: 0,
992 cache_creation_input_tokens: 0,
993 };
994 let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
996 assert!((cost - 9.1).abs() < 1e-9, "unexpected cost: {cost}");
997 Ok(())
998 }
999
1000 #[test]
1004 fn output_keeps_the_dearer_output_rate() -> anyhow::Result<()> {
1005 use anyhow::Context;
1006 let pricing = Pricing::flat(1.0, 8.0).with_reasoning(3.0);
1007 let usage = Usage {
1008 served_speed: None,
1009 input_tokens: 0,
1010 output_tokens: 1_000_000,
1011 cached_input_tokens: 0,
1012 cache_creation_input_tokens: 0,
1013 };
1014 let cost = pricing.estimate_cost_usd(&usage).context("priced")?;
1016 assert!((cost - 8.0).abs() < 1e-9, "unexpected cost: {cost}");
1017 Ok(())
1018 }
1019
1020 #[test]
1021 fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
1022 use anyhow::Context;
1023
1024 let caps = get_model_capabilities("anthropic", "claude-fable-5")
1025 .context("claude-fable-5 capabilities missing")?;
1026 assert_eq!(caps.context_window, Some(1_000_000));
1027 assert_eq!(caps.max_output_tokens, Some(128_000));
1028 assert!(caps.supports_thinking);
1029 assert!(caps.supports_adaptive_thinking);
1030 assert_eq!(caps.source_status, SourceStatus::Official);
1031 let pricing = caps.pricing.context("pricing missing")?;
1032 let input = pricing.input.context("input price missing")?;
1033 let output = pricing.output.context("output price missing")?;
1034 assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
1035 assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
1036 Ok(())
1037 }
1038
1039 #[test]
1040 fn test_lookup_anthropic_opus_5() -> anyhow::Result<()> {
1041 use anyhow::Context;
1042
1043 let caps = get_model_capabilities("anthropic", "claude-opus-5")
1044 .context("claude-opus-5 capabilities missing")?;
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 assert_eq!(caps.source_status, SourceStatus::Official);
1051 let pricing = caps.pricing.context("pricing missing")?;
1052 let input = pricing.input.context("input price missing")?;
1053 let output = pricing.output.context("output price missing")?;
1054 assert!((input.usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1055 assert!((output.usd_per_million_tokens - 25.0).abs() < f64::EPSILON);
1056 Ok(())
1057 }
1058
1059 #[test]
1060 fn test_lookup_anthropic_opus_48() {
1061 let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
1062 assert_eq!(caps.context_window, Some(1_000_000));
1063 assert_eq!(caps.max_output_tokens, Some(128_000));
1064 assert!(caps.supports_thinking);
1065 assert!(caps.supports_adaptive_thinking);
1066 }
1067
1068 #[test]
1069 fn test_lookup_anthropic_opus_46() {
1070 let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
1071 assert_eq!(caps.context_window, Some(1_000_000));
1072 assert_eq!(caps.max_output_tokens, Some(128_000));
1073 assert!(caps.supports_adaptive_thinking);
1074 }
1075
1076 #[test]
1077 fn test_lookup_anthropic_sonnet_5() {
1078 let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
1079 assert_eq!(caps.context_window, Some(1_000_000));
1080 assert_eq!(caps.max_output_tokens, Some(128_000));
1081 assert!(caps.supports_thinking);
1082 assert!(caps.supports_adaptive_thinking);
1084 }
1085
1086 #[test]
1087 fn test_lookup_anthropic_sonnet_46() {
1088 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
1089 assert_eq!(caps.context_window, Some(1_000_000));
1090 assert_eq!(caps.max_output_tokens, Some(64_000));
1091 assert!(caps.supports_adaptive_thinking);
1092 }
1093
1094 #[test]
1095 fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
1096 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
1097 assert!(!caps.supports_adaptive_thinking);
1098 }
1099
1100 #[test]
1101 fn test_lookup_openai_pricing() {
1102 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1103 let pricing = caps.pricing.unwrap();
1104 assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
1105 assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
1106 }
1107
1108 #[test]
1109 fn test_lookup_openai_gpt54() {
1110 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1111 assert_eq!(caps.context_window, Some(1_050_000));
1112 assert_eq!(caps.max_output_tokens, Some(128_000));
1113 assert!(caps.supports_thinking);
1114 assert_eq!(caps.source_status, SourceStatus::Official);
1115 }
1116
1117 #[test]
1118 fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
1119 use anyhow::Context;
1120
1121 let caps = get_model_capabilities("openai", "gpt-5.2-pro")
1122 .context("gpt-5.2-pro capabilities missing")?;
1123 assert_eq!(caps.context_window, Some(400_000));
1124 assert_eq!(caps.max_output_tokens, Some(128_000));
1125 assert!(caps.supports_thinking);
1126 assert_eq!(caps.source_status, SourceStatus::Official);
1127 let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
1128 let input = pricing.input.context("input price missing")?;
1129 let output = pricing.output.context("output price missing")?;
1130 assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
1131 assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
1132 Ok(())
1133 }
1134
1135 #[test]
1136 fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
1137 use anyhow::Context as _;
1138
1139 for (model_id, input, cached_input, output, cache_write_note) in [
1140 ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
1141 ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
1142 ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
1143 ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
1144 ] {
1145 let caps = get_model_capabilities("openai", model_id)
1146 .with_context(|| format!("{model_id} capabilities missing"))?;
1147 assert_eq!(caps.context_window, Some(1_050_000));
1148 assert_eq!(caps.max_output_tokens, Some(128_000));
1149 assert!(caps.supports_thinking);
1150 assert!(caps.supports_adaptive_thinking);
1151 assert_eq!(caps.source_status, SourceStatus::Official);
1152 let pricing = caps
1153 .pricing
1154 .with_context(|| format!("{model_id} pricing missing"))?;
1155 assert_eq!(pricing.input, Some(PricePoint::new(input)));
1156 assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
1157 assert_eq!(pricing.output, Some(PricePoint::new(output)));
1158 assert!(pricing.notes.is_some_and(|notes| {
1159 notes.contains(cache_write_note) && notes.contains("more than 272K")
1160 }));
1161 }
1162
1163 Ok(())
1164 }
1165
1166 #[test]
1167 fn test_lookup_openai_gpt53_codex() {
1168 let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
1169 assert_eq!(caps.context_window, Some(400_000));
1170 assert_eq!(caps.max_output_tokens, Some(128_000));
1171 assert!(caps.supports_adaptive_thinking);
1172 assert!(caps.supports_thinking);
1173 assert_eq!(caps.source_status, SourceStatus::Official);
1174 }
1175
1176 #[test]
1177 fn test_lookup_gemini_preview_models() {
1178 let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1179 assert_eq!(flash.context_window, Some(1_048_576));
1180 assert!(flash.supports_thinking);
1181
1182 let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1183 assert_eq!(pro.max_output_tokens, Some(65_536));
1184 assert!(pro.supports_thinking);
1185 }
1186
1187 #[test]
1188 fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1189 let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1191 assert!(deepseek.supports_thinking);
1192 assert_eq!(deepseek.max_output_tokens, Some(384_000));
1193 let pricing = deepseek.pricing.unwrap();
1194 assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1195 assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1196
1197 let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1199 assert!(glm.supports_thinking);
1200 assert_eq!(glm.max_output_tokens, Some(131_072));
1201 let glm_pricing = glm.pricing.unwrap();
1202 assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1203 assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1204
1205 let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1207 assert!(kimi_thinking.supports_thinking);
1208 assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1209 assert!(
1210 kimi_thinking
1211 .pricing
1212 .unwrap()
1213 .output
1214 .unwrap()
1215 .usd_per_million_tokens
1216 > 0.0
1217 );
1218 }
1219
1220 #[test]
1221 fn test_lookup_open_non_reasoning_kimi_models() {
1222 let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1224 assert!(!k26.supports_thinking);
1225 assert_eq!(k26.max_output_tokens, Some(65_536));
1226 assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1227
1228 let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1229 assert!(!k25_native.supports_thinking);
1230 assert_eq!(k25_native.max_output_tokens, Some(32_768));
1231 }
1232
1233 #[test]
1234 fn test_lookup_all_open_models_resolve() {
1235 for model_id in [
1238 "z-ai/glm-5.1",
1239 "glm-5",
1240 "moonshotai/kimi-k2.6",
1241 "moonshotai/kimi-k2.5",
1242 "kimi-k2.5",
1243 "kimi-k2-thinking",
1244 "deepseek/deepseek-v4-pro",
1245 "deepseek-v4-pro",
1246 "deepseek/deepseek-v4-flash",
1247 "deepseek-v4-flash",
1248 "MiniMax-M2.5",
1249 "minimax/minimax-m2.5",
1250 ] {
1251 let caps = get_model_capabilities("openai", model_id)
1252 .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1253 assert!(
1254 caps.pricing.is_some(),
1255 "pricing should be populated for {model_id}"
1256 );
1257 assert!(
1258 caps.max_output_tokens.is_some_and(|m| m > 0),
1259 "max_output_tokens should be non-zero for {model_id}"
1260 );
1261 assert!(
1262 caps.context_window.is_some_and(|c| c > 0),
1263 "context_window should be non-zero for {model_id}"
1264 );
1265 }
1266 }
1267
1268 #[test]
1269 fn test_lookup_minimax_native_pricing() {
1270 let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1271 assert!(native.supports_thinking);
1272 let pricing = native.pricing.unwrap();
1273 assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1274 assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1275 assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1278 }
1279
1280 #[test]
1281 fn test_estimate_cost_usd() {
1282 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1283 let cost = caps
1284 .estimate_cost_usd(&Usage {
1285 served_speed: None,
1286 input_tokens: 2_000,
1287 output_tokens: 1_000,
1288 cached_input_tokens: 0,
1289 cache_creation_input_tokens: 0,
1290 })
1291 .unwrap();
1292 assert!((cost - 0.0075).abs() < f64::EPSILON);
1293 }
1294
1295 #[test]
1296 fn test_estimate_cost_usd_with_cached_input() {
1297 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1298 let cost = caps
1299 .estimate_cost_usd(&Usage {
1300 served_speed: None,
1301 input_tokens: 2_000,
1302 output_tokens: 1_000,
1303 cached_input_tokens: 1_000,
1304 cache_creation_input_tokens: 0,
1305 })
1306 .unwrap();
1307 assert!((cost - 0.01775).abs() < f64::EPSILON);
1308 }
1309}