1use agent_sdk_foundation::llm::Usage;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SourceStatus {
5 Official,
6 Derived,
7 Unverified,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct PricePoint {
12 pub usd_per_million_tokens: f64,
14}
15
16impl PricePoint {
17 #[must_use]
18 pub const fn new(usd_per_million_tokens: f64) -> Self {
19 Self {
20 usd_per_million_tokens,
21 }
22 }
23
24 #[must_use]
25 pub fn estimate_cost_usd(self, tokens: u32) -> f64 {
26 (f64::from(tokens) / 1_000_000.0) * self.usd_per_million_tokens
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct Pricing {
32 pub input: Option<PricePoint>,
33 pub output: Option<PricePoint>,
34 pub cached_input: Option<PricePoint>,
35 pub notes: Option<&'static str>,
36}
37
38impl Pricing {
39 #[must_use]
40 pub const fn flat(input: f64, output: f64) -> Self {
41 Self {
42 input: Some(PricePoint::new(input)),
43 output: Some(PricePoint::new(output)),
44 cached_input: None,
45 notes: None,
46 }
47 }
48
49 #[must_use]
50 pub const fn flat_with_cached(input: f64, output: f64, cached_input: f64) -> Self {
51 Self {
52 input: Some(PricePoint::new(input)),
53 output: Some(PricePoint::new(output)),
54 cached_input: Some(PricePoint::new(cached_input)),
55 notes: None,
56 }
57 }
58
59 #[must_use]
60 pub const fn with_notes(mut self, notes: &'static str) -> Self {
61 self.notes = Some(notes);
62 self
63 }
64
65 #[must_use]
66 pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
67 let cached_input_tokens = usage.cached_input_tokens.min(usage.input_tokens);
68 let uncached_input_tokens = usage.input_tokens.saturating_sub(cached_input_tokens);
69
70 let input = match (self.input, self.cached_input) {
71 (Some(input), Some(cached_input)) => Some(
72 input.estimate_cost_usd(uncached_input_tokens)
73 + cached_input.estimate_cost_usd(cached_input_tokens),
74 ),
75 (Some(input), None) => Some(input.estimate_cost_usd(usage.input_tokens)),
76 (None, Some(cached_input)) => Some(cached_input.estimate_cost_usd(cached_input_tokens)),
77 (None, None) => None,
78 };
79 let output = self
80 .output
81 .map(|p| p.estimate_cost_usd(usage.output_tokens));
82 match (input, output) {
83 (Some(input), Some(output)) => Some(input + output),
84 (Some(input), None) => Some(input),
85 (None, Some(output)) => Some(output),
86 (None, None) => None,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct ModelCapabilities {
93 pub provider: &'static str,
94 pub model_id: &'static str,
95 pub context_window: Option<u32>,
96 pub max_output_tokens: Option<u32>,
97 pub pricing: Option<Pricing>,
98 pub supports_thinking: bool,
99 pub supports_adaptive_thinking: bool,
100 pub source_url: &'static str,
101 pub source_status: SourceStatus,
102 pub notes: Option<&'static str>,
103}
104
105impl ModelCapabilities {
106 #[must_use]
107 pub fn estimate_cost_usd(&self, usage: &Usage) -> Option<f64> {
108 self.pricing
109 .as_ref()
110 .and_then(|p| p.estimate_cost_usd(usage))
111 }
112}
113
114const ANTHROPIC_MODELS_URL: &str =
115 "https://docs.anthropic.com/en/docs/about-claude/models/all-models";
116const OPENAI_MODELS_URL: &str = "https://developers.openai.com/api/docs/models";
117const OPENAI_PRICING_URL: &str = "https://developers.openai.com/api/docs/pricing";
118const OPENAI_GPT56_SOL_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-sol";
119const OPENAI_GPT56_TERRA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-terra";
120const OPENAI_GPT56_LUNA_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.6-luna";
121const OPENAI_GPT54_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.4";
122const OPENAI_GPT53_CODEX_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.3-codex";
123const OPENAI_GPT52_PRO_URL: &str = "https://developers.openai.com/api/docs/models/gpt-5.2-pro";
124const GOOGLE_MODELS_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
125const GOOGLE_PRICING_URL: &str = "https://ai.google.dev/gemini-api/docs/pricing";
126
127const OPENROUTER_GLM51_URL: &str = "https://openrouter.ai/z-ai/glm-5.1";
130const ZAI_GLM5_PRICING_URL: &str = "https://docs.z.ai/guides/overview/pricing";
131const OPENROUTER_KIMI_K26_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.6";
132const OPENROUTER_KIMI_K25_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2.5";
133const KIMI_K25_AA_URL: &str = "https://artificialanalysis.ai/models/kimi-k2-5";
134const OPENROUTER_KIMI_K2_THINKING_URL: &str = "https://openrouter.ai/moonshotai/kimi-k2-thinking";
135const OPENROUTER_DEEPSEEK_V4_PRO_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-pro";
136const OPENROUTER_DEEPSEEK_V4_FLASH_URL: &str = "https://openrouter.ai/deepseek/deepseek-v4-flash";
137const DEEPSEEK_PRICING_URL: &str = "https://api-docs.deepseek.com/quick_start/pricing";
138const MINIMAX_PRICING_URL: &str = "https://platform.minimax.io/docs/guides/pricing-paygo";
139const OPENROUTER_MINIMAX_M25_URL: &str = "https://openrouter.ai/minimax/minimax-m2.5";
140
141const MODEL_CAPABILITIES: &[ModelCapabilities] = &[
142 ModelCapabilities {
144 provider: "anthropic",
145 model_id: "claude-fable-5",
146 context_window: Some(1_000_000),
147 max_output_tokens: Some(128_000),
148 pricing: Some(Pricing::flat(10.0, 50.0).with_notes("Anthropic Fable 5 official pricing: $10 input / $50 output per 1M tokens.")),
149 supports_thinking: true,
150 supports_adaptive_thinking: true,
151 source_url: ANTHROPIC_MODELS_URL,
152 source_status: SourceStatus::Official,
153 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."),
154 },
155 ModelCapabilities {
156 provider: "anthropic",
157 model_id: "claude-opus-4-8",
158 context_window: Some(1_000_000),
159 max_output_tokens: Some(128_000),
160 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.")),
161 supports_thinking: true,
162 supports_adaptive_thinking: true,
163 source_url: ANTHROPIC_MODELS_URL,
164 source_status: SourceStatus::Derived,
165 notes: Some("Opus 4.8 requires adaptive thinking — `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config."),
166 },
167 ModelCapabilities {
168 provider: "anthropic",
169 model_id: "claude-opus-4-7",
170 context_window: Some(1_000_000),
171 max_output_tokens: Some(128_000),
172 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.")),
173 supports_thinking: true,
174 supports_adaptive_thinking: true,
175 source_url: ANTHROPIC_MODELS_URL,
176 source_status: SourceStatus::Derived,
177 notes: Some("Opus 4.7 requires adaptive thinking — `ThinkingMode::Enabled { budget_tokens }` is rejected by the Anthropic API. The SDK fails fast in validate_thinking_config."),
178 },
179 ModelCapabilities {
180 provider: "anthropic",
181 model_id: "claude-opus-4-6",
182 context_window: Some(1_000_000),
183 max_output_tokens: Some(128_000),
184 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.")),
185 supports_thinking: true,
186 supports_adaptive_thinking: true,
187 source_url: ANTHROPIC_MODELS_URL,
188 source_status: SourceStatus::Derived,
189 notes: Some("Current Anthropic docs show this model alongside 200K/128K markers."),
190 },
191 ModelCapabilities {
192 provider: "anthropic",
193 model_id: "claude-sonnet-5",
194 context_window: Some(1_000_000),
195 max_output_tokens: Some(128_000),
196 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.")),
197 supports_thinking: true,
198 supports_adaptive_thinking: true,
199 source_url: ANTHROPIC_MODELS_URL,
200 source_status: SourceStatus::Official,
201 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)."),
202 },
203 ModelCapabilities {
204 provider: "anthropic",
205 model_id: "claude-sonnet-4-6",
206 context_window: Some(1_000_000),
207 max_output_tokens: Some(64_000),
208 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
209 supports_thinking: true,
210 supports_adaptive_thinking: true,
211 source_url: ANTHROPIC_MODELS_URL,
212 source_status: SourceStatus::Derived,
213 notes: Some("Anthropic docs list Sonnet 4.6; user confirmed adaptive thinking support."),
214 },
215 ModelCapabilities {
216 provider: "anthropic",
217 model_id: "claude-sonnet-4-5-20250929",
218 context_window: Some(200_000),
219 max_output_tokens: Some(64_000),
220 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
221 supports_thinking: true,
222 supports_adaptive_thinking: false,
223 source_url: ANTHROPIC_MODELS_URL,
224 source_status: SourceStatus::Derived,
225 notes: None,
226 },
227 ModelCapabilities {
228 provider: "anthropic",
229 model_id: "claude-haiku-4-5-20251001",
230 context_window: Some(200_000),
231 max_output_tokens: Some(64_000),
232 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
233 supports_thinking: true,
234 supports_adaptive_thinking: false,
235 source_url: ANTHROPIC_MODELS_URL,
236 source_status: SourceStatus::Derived,
237 notes: None,
238 },
239 ModelCapabilities {
240 provider: "anthropic",
241 model_id: "claude-sonnet-4-20250514",
242 context_window: Some(200_000),
243 max_output_tokens: Some(64_000),
244 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
245 supports_thinking: true,
246 supports_adaptive_thinking: false,
247 source_url: ANTHROPIC_MODELS_URL,
248 source_status: SourceStatus::Derived,
249 notes: None,
250 },
251 ModelCapabilities {
252 provider: "anthropic",
253 model_id: "claude-opus-4-20250514",
254 context_window: Some(200_000),
255 max_output_tokens: Some(32_000),
256 pricing: Some(Pricing::flat(15.0, 75.0).with_notes("Anthropic Opus tier pricing; verify exact current SKU mapping before billing-critical use.")),
257 supports_thinking: true,
258 supports_adaptive_thinking: false,
259 source_url: ANTHROPIC_MODELS_URL,
260 source_status: SourceStatus::Derived,
261 notes: None,
262 },
263 ModelCapabilities {
264 provider: "anthropic",
265 model_id: "claude-3-5-sonnet-20241022",
266 context_window: Some(200_000),
267 max_output_tokens: Some(8_192),
268 pricing: Some(Pricing::flat(3.0, 15.0).with_notes("Anthropic Sonnet tier pricing; verify exact current SKU mapping before billing-critical use.")),
269 supports_thinking: true,
270 supports_adaptive_thinking: false,
271 source_url: ANTHROPIC_MODELS_URL,
272 source_status: SourceStatus::Derived,
273 notes: None,
274 },
275 ModelCapabilities {
276 provider: "anthropic",
277 model_id: "claude-3-5-haiku-20241022",
278 context_window: Some(200_000),
279 max_output_tokens: Some(8_192),
280 pricing: Some(Pricing::flat(1.0, 5.0).with_notes("Anthropic Haiku tier pricing; verify exact current SKU mapping before billing-critical use.")),
281 supports_thinking: true,
282 supports_adaptive_thinking: false,
283 source_url: ANTHROPIC_MODELS_URL,
284 source_status: SourceStatus::Derived,
285 notes: None,
286 },
287 ModelCapabilities {
289 provider: "openai",
290 model_id: "gpt-5.6",
291 context_window: Some(1_050_000),
292 max_output_tokens: Some(128_000),
293 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
294 "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.",
295 )),
296 supports_thinking: true,
297 supports_adaptive_thinking: true,
298 source_url: OPENAI_GPT56_SOL_URL,
299 source_status: SourceStatus::Official,
300 notes: Some("Official alias for GPT-5.6 Sol."),
301 },
302 ModelCapabilities {
303 provider: "openai",
304 model_id: "gpt-5.6-sol",
305 context_window: Some(1_050_000),
306 max_output_tokens: Some(128_000),
307 pricing: Some(Pricing::flat_with_cached(5.0, 30.0, 0.5).with_notes(
308 "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.",
309 )),
310 supports_thinking: true,
311 supports_adaptive_thinking: true,
312 source_url: OPENAI_GPT56_SOL_URL,
313 source_status: SourceStatus::Official,
314 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
315 },
316 ModelCapabilities {
317 provider: "openai",
318 model_id: "gpt-5.6-terra",
319 context_window: Some(1_050_000),
320 max_output_tokens: Some(128_000),
321 pricing: Some(Pricing::flat_with_cached(2.5, 15.0, 0.25).with_notes(
322 "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.",
323 )),
324 supports_thinking: true,
325 supports_adaptive_thinking: true,
326 source_url: OPENAI_GPT56_TERRA_URL,
327 source_status: SourceStatus::Official,
328 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
329 },
330 ModelCapabilities {
331 provider: "openai",
332 model_id: "gpt-5.6-luna",
333 context_window: Some(1_050_000),
334 max_output_tokens: Some(128_000),
335 pricing: Some(Pricing::flat_with_cached(1.0, 6.0, 0.1).with_notes(
336 "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.",
337 )),
338 supports_thinking: true,
339 supports_adaptive_thinking: true,
340 source_url: OPENAI_GPT56_LUNA_URL,
341 source_status: SourceStatus::Official,
342 notes: Some("Supports Chat Completions and Responses, 1.05M context, and 128K max output."),
343 },
344 ModelCapabilities {
345 provider: "openai",
346 model_id: "gpt-5.4",
347 context_window: Some(1_050_000),
348 max_output_tokens: Some(128_000),
349 pricing: Some(Pricing::flat_with_cached(2.50, 15.0, 0.25)),
350 supports_thinking: true,
351 supports_adaptive_thinking: false,
352 source_url: OPENAI_GPT54_URL,
353 source_status: SourceStatus::Official,
354 notes: Some("OpenAI model docs list 1.05M context, 128K max output, and reasoning.effort support."),
355 },
356 ModelCapabilities {
357 provider: "openai",
358 model_id: "gpt-5.3-codex",
359 context_window: Some(400_000),
360 max_output_tokens: Some(128_000),
361 pricing: Some(Pricing::flat_with_cached(1.50, 6.0, 0.375)),
362 supports_thinking: true,
363 supports_adaptive_thinking: true,
364 source_url: OPENAI_GPT53_CODEX_URL,
365 source_status: SourceStatus::Official,
366 notes: Some("OpenAI model docs list Responses-only access, a 272K maximum input, 128K maximum output, and reasoning.effort levels."),
367 },
368 ModelCapabilities {
369 provider: "openai",
370 model_id: "gpt-5",
371 context_window: Some(400_000),
372 max_output_tokens: Some(128_000),
373 pricing: Some(Pricing::flat_with_cached(1.25, 10.0, 0.125)),
374 supports_thinking: false,
375 supports_adaptive_thinking: false,
376 source_url: OPENAI_PRICING_URL,
377 source_status: SourceStatus::Official,
378 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
379 },
380 ModelCapabilities {
381 provider: "openai",
382 model_id: "gpt-5-mini",
383 context_window: Some(400_000),
384 max_output_tokens: Some(128_000),
385 pricing: Some(Pricing::flat_with_cached(0.125, 1.0, 0.0125)),
386 supports_thinking: false,
387 supports_adaptive_thinking: false,
388 source_url: OPENAI_PRICING_URL,
389 source_status: SourceStatus::Official,
390 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
391 },
392 ModelCapabilities {
393 provider: "openai",
394 model_id: "gpt-5-nano",
395 context_window: Some(400_000),
396 max_output_tokens: Some(128_000),
397 pricing: Some(Pricing::flat_with_cached(0.025, 0.20, 0.0025)),
398 supports_thinking: false,
399 supports_adaptive_thinking: false,
400 source_url: OPENAI_PRICING_URL,
401 source_status: SourceStatus::Official,
402 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
403 },
404 ModelCapabilities {
405 provider: "openai",
406 model_id: "gpt-5.2-instant",
407 context_window: Some(400_000),
408 max_output_tokens: Some(128_000),
409 pricing: None,
410 supports_thinking: false,
411 supports_adaptive_thinking: false,
412 source_url: OPENAI_MODELS_URL,
413 source_status: SourceStatus::Unverified,
414 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
415 },
416 ModelCapabilities {
417 provider: "openai",
418 model_id: "gpt-5.2-thinking",
419 context_window: Some(400_000),
420 max_output_tokens: Some(128_000),
421 pricing: None,
422 supports_thinking: true,
423 supports_adaptive_thinking: false,
424 source_url: OPENAI_MODELS_URL,
425 source_status: SourceStatus::Unverified,
426 notes: Some("Model exists in OpenAI docs, but pricing was not extracted from the official pricing page in this pass."),
427 },
428 ModelCapabilities {
429 provider: "openai",
430 model_id: "gpt-5.2-pro",
431 context_window: Some(400_000),
432 max_output_tokens: Some(128_000),
433 pricing: Some(Pricing::flat(21.0, 168.0)),
434 supports_thinking: true,
435 supports_adaptive_thinking: false,
436 source_url: OPENAI_GPT52_PRO_URL,
437 source_status: SourceStatus::Official,
438 notes: Some("Responses-only pro model. Supports medium, high, and xhigh reasoning effort."),
439 },
440 ModelCapabilities {
441 provider: "openai",
442 model_id: "gpt-5.2-codex",
443 context_window: Some(400_000),
444 max_output_tokens: Some(128_000),
445 pricing: None,
446 supports_thinking: false,
447 supports_adaptive_thinking: false,
448 source_url: OPENAI_MODELS_URL,
449 source_status: SourceStatus::Unverified,
450 notes: Some("Model presence confirmed from OpenAI docs; pricing not yet extracted in this pass."),
451 },
452 ModelCapabilities {
453 provider: "openai",
454 model_id: "o3",
455 context_window: Some(200_000),
456 max_output_tokens: Some(100_000),
457 pricing: Some(Pricing::flat(1.0, 4.0)),
458 supports_thinking: true,
459 supports_adaptive_thinking: false,
460 source_url: OPENAI_PRICING_URL,
461 source_status: SourceStatus::Official,
462 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
463 },
464 ModelCapabilities {
465 provider: "openai",
466 model_id: "o3-mini",
467 context_window: Some(200_000),
468 max_output_tokens: Some(100_000),
469 pricing: Some(Pricing::flat(0.55, 2.20)),
470 supports_thinking: true,
471 supports_adaptive_thinking: false,
472 source_url: OPENAI_PRICING_URL,
473 source_status: SourceStatus::Official,
474 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
475 },
476 ModelCapabilities {
477 provider: "openai",
478 model_id: "o4-mini",
479 context_window: Some(200_000),
480 max_output_tokens: Some(100_000),
481 pricing: Some(Pricing::flat(0.55, 2.20)),
482 supports_thinking: true,
483 supports_adaptive_thinking: false,
484 source_url: OPENAI_PRICING_URL,
485 source_status: SourceStatus::Official,
486 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
487 },
488 ModelCapabilities {
489 provider: "openai",
490 model_id: "o1",
491 context_window: Some(200_000),
492 max_output_tokens: Some(100_000),
493 pricing: Some(Pricing::flat(7.50, 30.0)),
494 supports_thinking: true,
495 supports_adaptive_thinking: false,
496 source_url: OPENAI_PRICING_URL,
497 source_status: SourceStatus::Official,
498 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
499 },
500 ModelCapabilities {
501 provider: "openai",
502 model_id: "o1-mini",
503 context_window: Some(200_000),
504 max_output_tokens: Some(100_000),
505 pricing: Some(Pricing::flat(0.55, 2.20)),
506 supports_thinking: true,
507 supports_adaptive_thinking: false,
508 source_url: OPENAI_PRICING_URL,
509 source_status: SourceStatus::Official,
510 notes: Some("Pricing verified from OpenAI pricing page. Context/max output still need clean extraction from models docs."),
511 },
512 ModelCapabilities {
513 provider: "openai",
514 model_id: "gpt-4.1",
515 context_window: Some(1_000_000),
516 max_output_tokens: Some(16_384),
517 pricing: Some(Pricing::flat(1.0, 4.0)),
518 supports_thinking: false,
519 supports_adaptive_thinking: false,
520 source_url: OPENAI_PRICING_URL,
521 source_status: SourceStatus::Official,
522 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
523 },
524 ModelCapabilities {
525 provider: "openai",
526 model_id: "gpt-4.1-mini",
527 context_window: Some(1_000_000),
528 max_output_tokens: Some(16_384),
529 pricing: Some(Pricing::flat(0.20, 0.80)),
530 supports_thinking: false,
531 supports_adaptive_thinking: false,
532 source_url: OPENAI_PRICING_URL,
533 source_status: SourceStatus::Official,
534 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
535 },
536 ModelCapabilities {
537 provider: "openai",
538 model_id: "gpt-4.1-nano",
539 context_window: Some(1_000_000),
540 max_output_tokens: Some(16_384),
541 pricing: Some(Pricing::flat(0.05, 0.20)),
542 supports_thinking: false,
543 supports_adaptive_thinking: false,
544 source_url: OPENAI_PRICING_URL,
545 source_status: SourceStatus::Official,
546 notes: Some("Pricing verified from OpenAI pricing page. Context window from model family docs/notes."),
547 },
548 ModelCapabilities {
549 provider: "openai",
550 model_id: "gpt-4o",
551 context_window: Some(128_000),
552 max_output_tokens: Some(16_384),
553 pricing: Some(Pricing::flat(1.25, 5.0)),
554 supports_thinking: false,
555 supports_adaptive_thinking: false,
556 source_url: OPENAI_PRICING_URL,
557 source_status: SourceStatus::Official,
558 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
559 },
560 ModelCapabilities {
561 provider: "openai",
562 model_id: "gpt-4o-mini",
563 context_window: Some(128_000),
564 max_output_tokens: Some(16_384),
565 pricing: Some(Pricing::flat(0.075, 0.30)),
566 supports_thinking: false,
567 supports_adaptive_thinking: false,
568 source_url: OPENAI_PRICING_URL,
569 source_status: SourceStatus::Official,
570 notes: Some("Pricing verified from OpenAI pricing page. Context/max output from existing runtime assumptions."),
571 },
572 ModelCapabilities {
574 provider: "gemini",
575 model_id: "gemini-3.1-pro-preview",
576 context_window: Some(1_048_576),
577 max_output_tokens: Some(65_536),
578 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.")),
579 supports_thinking: true,
580 supports_adaptive_thinking: false,
581 source_url: GOOGLE_PRICING_URL,
582 source_status: SourceStatus::Official,
583 notes: Some("Pricing sourced from Gemini 3.1 Pro Preview docs."),
584 },
585 ModelCapabilities {
586 provider: "gemini",
587 model_id: "gemini-3.1-pro",
588 context_window: Some(1_048_576),
589 max_output_tokens: Some(65_536),
590 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.")),
591 supports_thinking: true,
592 supports_adaptive_thinking: false,
593 source_url: GOOGLE_PRICING_URL,
594 source_status: SourceStatus::Derived,
595 notes: Some("Legacy Gemini 3.1 Pro alias retained for compatibility; prefer gemini-3.1-pro-preview."),
596 },
597 ModelCapabilities {
598 provider: "gemini",
599 model_id: "gemini-3.1-flash-lite-preview",
600 context_window: Some(1_048_576),
601 max_output_tokens: Some(65_536),
602 pricing: None,
603 supports_thinking: true,
604 supports_adaptive_thinking: false,
605 source_url: GOOGLE_MODELS_URL,
606 source_status: SourceStatus::Unverified,
607 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
608 },
609 ModelCapabilities {
610 provider: "gemini",
611 model_id: "gemini-3-flash-preview",
612 context_window: Some(1_048_576),
613 max_output_tokens: Some(65_536),
614 pricing: None,
615 supports_thinking: true,
616 supports_adaptive_thinking: false,
617 source_url: GOOGLE_MODELS_URL,
618 source_status: SourceStatus::Unverified,
619 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
620 },
621 ModelCapabilities {
622 provider: "gemini",
623 model_id: "gemini-3.0-flash",
624 context_window: Some(1_048_576),
625 max_output_tokens: Some(65_536),
626 pricing: None,
627 supports_thinking: true,
628 supports_adaptive_thinking: false,
629 source_url: GOOGLE_MODELS_URL,
630 source_status: SourceStatus::Derived,
631 notes: Some("Legacy Gemini 3.0 Flash model retained for compatibility; prefer gemini-3-flash-preview."),
632 },
633 ModelCapabilities {
634 provider: "gemini",
635 model_id: "gemini-3.0-pro",
636 context_window: Some(1_048_576),
637 max_output_tokens: Some(65_536),
638 pricing: None,
639 supports_thinking: true,
640 supports_adaptive_thinking: false,
641 source_url: GOOGLE_MODELS_URL,
642 source_status: SourceStatus::Unverified,
643 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
644 },
645 ModelCapabilities {
646 provider: "gemini",
647 model_id: "gemini-2.5-flash",
648 context_window: Some(1_000_000),
649 max_output_tokens: Some(65_536),
650 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.")),
651 supports_thinking: true,
652 supports_adaptive_thinking: false,
653 source_url: GOOGLE_PRICING_URL,
654 source_status: SourceStatus::Official,
655 notes: Some("Official docs state output pricing includes thinking tokens."),
656 },
657 ModelCapabilities {
658 provider: "gemini",
659 model_id: "gemini-2.5-pro",
660 context_window: Some(1_000_000),
661 max_output_tokens: Some(65_536),
662 pricing: None,
663 supports_thinking: true,
664 supports_adaptive_thinking: false,
665 source_url: GOOGLE_MODELS_URL,
666 source_status: SourceStatus::Unverified,
667 notes: Some("Model presence confirmed from Google docs, but pricing was not extracted in this pass."),
668 },
669 ModelCapabilities {
670 provider: "gemini",
671 model_id: "gemini-2.0-flash",
672 context_window: Some(1_000_000),
673 max_output_tokens: Some(8_192),
674 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.")),
675 supports_thinking: false,
676 supports_adaptive_thinking: false,
677 source_url: GOOGLE_PRICING_URL,
678 source_status: SourceStatus::Official,
679 notes: None,
680 },
681 ModelCapabilities {
682 provider: "gemini",
683 model_id: "gemini-2.0-flash-lite",
684 context_window: Some(1_000_000),
685 max_output_tokens: Some(8_192),
686 pricing: Some(Pricing::flat(0.075, 0.30)),
687 supports_thinking: false,
688 supports_adaptive_thinking: false,
689 source_url: GOOGLE_PRICING_URL,
690 source_status: SourceStatus::Official,
691 notes: None,
692 },
693 ModelCapabilities {
697 provider: "openai",
698 model_id: "z-ai/glm-5.1",
699 context_window: Some(202_752),
700 max_output_tokens: Some(131_072),
701 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.")),
702 supports_thinking: true,
703 supports_adaptive_thinking: false,
704 source_url: OPENROUTER_GLM51_URL,
705 source_status: SourceStatus::Derived,
706 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."),
707 },
708 ModelCapabilities {
709 provider: "openai",
710 model_id: "glm-5",
711 context_window: Some(200_000),
712 max_output_tokens: Some(131_072),
713 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).")),
714 supports_thinking: true,
715 supports_adaptive_thinking: false,
716 source_url: ZAI_GLM5_PRICING_URL,
717 source_status: SourceStatus::Derived,
718 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."),
719 },
720 ModelCapabilities {
721 provider: "openai",
722 model_id: "moonshotai/kimi-k2.6",
723 context_window: Some(262_144),
724 max_output_tokens: Some(65_536),
725 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.")),
726 supports_thinking: false,
727 supports_adaptive_thinking: false,
728 source_url: OPENROUTER_KIMI_K26_URL,
729 source_status: SourceStatus::Derived,
730 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."),
731 },
732 ModelCapabilities {
733 provider: "openai",
734 model_id: "moonshotai/kimi-k2.5",
735 context_window: Some(262_144),
736 max_output_tokens: Some(32_768),
737 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.")),
738 supports_thinking: false,
739 supports_adaptive_thinking: false,
740 source_url: OPENROUTER_KIMI_K25_URL,
741 source_status: SourceStatus::Derived,
742 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."),
743 },
744 ModelCapabilities {
745 provider: "openai",
746 model_id: "kimi-k2.5",
747 context_window: Some(262_144),
748 max_output_tokens: Some(32_768),
749 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.")),
750 supports_thinking: false,
751 supports_adaptive_thinking: false,
752 source_url: KIMI_K25_AA_URL,
753 source_status: SourceStatus::Unverified,
754 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."),
755 },
756 ModelCapabilities {
757 provider: "openai",
758 model_id: "kimi-k2-thinking",
759 context_window: Some(262_144),
760 max_output_tokens: Some(131_072),
761 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.")),
762 supports_thinking: true,
763 supports_adaptive_thinking: false,
764 source_url: OPENROUTER_KIMI_K2_THINKING_URL,
765 source_status: SourceStatus::Unverified,
766 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."),
767 },
768 ModelCapabilities {
769 provider: "openai",
770 model_id: "deepseek/deepseek-v4-pro",
771 context_window: Some(1_048_576),
772 max_output_tokens: Some(384_000),
773 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.")),
774 supports_thinking: true,
775 supports_adaptive_thinking: false,
776 source_url: OPENROUTER_DEEPSEEK_V4_PRO_URL,
777 source_status: SourceStatus::Derived,
778 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."),
779 },
780 ModelCapabilities {
781 provider: "openai",
782 model_id: "deepseek-v4-pro",
783 context_window: Some(1_048_576),
784 max_output_tokens: Some(384_000),
785 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.")),
786 supports_thinking: true,
787 supports_adaptive_thinking: false,
788 source_url: DEEPSEEK_PRICING_URL,
789 source_status: SourceStatus::Derived,
790 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."),
791 },
792 ModelCapabilities {
793 provider: "openai",
794 model_id: "deepseek/deepseek-v4-flash",
795 context_window: Some(1_048_576),
796 max_output_tokens: Some(384_000),
797 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.")),
798 supports_thinking: true,
799 supports_adaptive_thinking: false,
800 source_url: OPENROUTER_DEEPSEEK_V4_FLASH_URL,
801 source_status: SourceStatus::Derived,
802 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."),
803 },
804 ModelCapabilities {
805 provider: "openai",
806 model_id: "deepseek-v4-flash",
807 context_window: Some(1_048_576),
808 max_output_tokens: Some(384_000),
809 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.")),
810 supports_thinking: true,
811 supports_adaptive_thinking: false,
812 source_url: DEEPSEEK_PRICING_URL,
813 source_status: SourceStatus::Derived,
814 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."),
815 },
816 ModelCapabilities {
817 provider: "openai",
818 model_id: "MiniMax-M2.5",
819 context_window: Some(204_800),
820 max_output_tokens: Some(131_072),
821 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).")),
822 supports_thinking: true,
823 supports_adaptive_thinking: false,
824 source_url: MINIMAX_PRICING_URL,
825 source_status: SourceStatus::Derived,
826 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."),
827 },
828 ModelCapabilities {
829 provider: "openai",
830 model_id: "minimax/minimax-m2.5",
831 context_window: Some(204_800),
832 max_output_tokens: Some(131_072),
833 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).")),
834 supports_thinking: true,
835 supports_adaptive_thinking: false,
836 source_url: OPENROUTER_MINIMAX_M25_URL,
837 source_status: SourceStatus::Derived,
838 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."),
839 },
840];
841
842#[must_use]
843pub fn get_model_capabilities(
844 provider: &str,
845 model_id: &str,
846) -> Option<&'static ModelCapabilities> {
847 MODEL_CAPABILITIES.iter().find(|caps| {
848 caps.provider.eq_ignore_ascii_case(provider) && caps.model_id.eq_ignore_ascii_case(model_id)
849 })
850}
851
852#[must_use]
853pub fn default_max_output_tokens(provider: &str, model_id: &str) -> Option<u32> {
854 get_model_capabilities(provider, model_id).and_then(|caps| caps.max_output_tokens)
855}
856
857#[must_use]
858pub const fn supported_model_capabilities() -> &'static [ModelCapabilities] {
859 MODEL_CAPABILITIES
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 #[test]
867 fn test_lookup_anthropic_fable_5() -> anyhow::Result<()> {
868 use anyhow::Context;
869
870 let caps = get_model_capabilities("anthropic", "claude-fable-5")
871 .context("claude-fable-5 capabilities missing")?;
872 assert_eq!(caps.context_window, Some(1_000_000));
873 assert_eq!(caps.max_output_tokens, Some(128_000));
874 assert!(caps.supports_thinking);
875 assert!(caps.supports_adaptive_thinking);
876 assert_eq!(caps.source_status, SourceStatus::Official);
877 let pricing = caps.pricing.context("pricing missing")?;
878 let input = pricing.input.context("input price missing")?;
879 let output = pricing.output.context("output price missing")?;
880 assert!((input.usd_per_million_tokens - 10.0).abs() < f64::EPSILON);
881 assert!((output.usd_per_million_tokens - 50.0).abs() < f64::EPSILON);
882 Ok(())
883 }
884
885 #[test]
886 fn test_lookup_anthropic_opus_48() {
887 let caps = get_model_capabilities("anthropic", "claude-opus-4-8").unwrap();
888 assert_eq!(caps.context_window, Some(1_000_000));
889 assert_eq!(caps.max_output_tokens, Some(128_000));
890 assert!(caps.supports_thinking);
891 assert!(caps.supports_adaptive_thinking);
892 }
893
894 #[test]
895 fn test_lookup_anthropic_opus_46() {
896 let caps = get_model_capabilities("anthropic", "claude-opus-4-6").unwrap();
897 assert_eq!(caps.context_window, Some(1_000_000));
898 assert_eq!(caps.max_output_tokens, Some(128_000));
899 assert!(caps.supports_adaptive_thinking);
900 }
901
902 #[test]
903 fn test_lookup_anthropic_sonnet_5() {
904 let caps = get_model_capabilities("anthropic", "claude-sonnet-5").unwrap();
905 assert_eq!(caps.context_window, Some(1_000_000));
906 assert_eq!(caps.max_output_tokens, Some(128_000));
907 assert!(caps.supports_thinking);
908 assert!(caps.supports_adaptive_thinking);
910 }
911
912 #[test]
913 fn test_lookup_anthropic_sonnet_46() {
914 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-6").unwrap();
915 assert_eq!(caps.context_window, Some(1_000_000));
916 assert_eq!(caps.max_output_tokens, Some(64_000));
917 assert!(caps.supports_adaptive_thinking);
918 }
919
920 #[test]
921 fn test_lookup_anthropic_sonnet_45_disables_adaptive_thinking() {
922 let caps = get_model_capabilities("anthropic", "claude-sonnet-4-5-20250929").unwrap();
923 assert!(!caps.supports_adaptive_thinking);
924 }
925
926 #[test]
927 fn test_lookup_openai_pricing() {
928 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
929 let pricing = caps.pricing.unwrap();
930 assert!((pricing.input.unwrap().usd_per_million_tokens - 1.25).abs() < f64::EPSILON);
931 assert!((pricing.output.unwrap().usd_per_million_tokens - 5.0).abs() < f64::EPSILON);
932 }
933
934 #[test]
935 fn test_lookup_openai_gpt54() {
936 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
937 assert_eq!(caps.context_window, Some(1_050_000));
938 assert_eq!(caps.max_output_tokens, Some(128_000));
939 assert!(caps.supports_thinking);
940 assert_eq!(caps.source_status, SourceStatus::Official);
941 }
942
943 #[test]
944 fn test_lookup_openai_gpt52_pro() -> anyhow::Result<()> {
945 use anyhow::Context;
946
947 let caps = get_model_capabilities("openai", "gpt-5.2-pro")
948 .context("gpt-5.2-pro capabilities missing")?;
949 assert_eq!(caps.context_window, Some(400_000));
950 assert_eq!(caps.max_output_tokens, Some(128_000));
951 assert!(caps.supports_thinking);
952 assert_eq!(caps.source_status, SourceStatus::Official);
953 let pricing = caps.pricing.context("gpt-5.2-pro pricing missing")?;
954 let input = pricing.input.context("input price missing")?;
955 let output = pricing.output.context("output price missing")?;
956 assert!((input.usd_per_million_tokens - 21.0).abs() < f64::EPSILON);
957 assert!((output.usd_per_million_tokens - 168.0).abs() < f64::EPSILON);
958 Ok(())
959 }
960
961 #[test]
962 fn test_lookup_openai_gpt56_family() -> anyhow::Result<()> {
963 use anyhow::Context as _;
964
965 for (model_id, input, cached_input, output, cache_write_note) in [
966 ("gpt-5.6", 5.0, 0.5, 30.0, "$6.25/M"),
967 ("gpt-5.6-sol", 5.0, 0.5, 30.0, "$6.25/M"),
968 ("gpt-5.6-terra", 2.5, 0.25, 15.0, "$3.125/M"),
969 ("gpt-5.6-luna", 1.0, 0.1, 6.0, "$1.25/M"),
970 ] {
971 let caps = get_model_capabilities("openai", model_id)
972 .with_context(|| format!("{model_id} capabilities missing"))?;
973 assert_eq!(caps.context_window, Some(1_050_000));
974 assert_eq!(caps.max_output_tokens, Some(128_000));
975 assert!(caps.supports_thinking);
976 assert!(caps.supports_adaptive_thinking);
977 assert_eq!(caps.source_status, SourceStatus::Official);
978 let pricing = caps
979 .pricing
980 .with_context(|| format!("{model_id} pricing missing"))?;
981 assert_eq!(pricing.input, Some(PricePoint::new(input)));
982 assert_eq!(pricing.cached_input, Some(PricePoint::new(cached_input)));
983 assert_eq!(pricing.output, Some(PricePoint::new(output)));
984 assert!(pricing.notes.is_some_and(|notes| {
985 notes.contains(cache_write_note) && notes.contains("more than 272K")
986 }));
987 }
988
989 Ok(())
990 }
991
992 #[test]
993 fn test_lookup_openai_gpt53_codex() {
994 let caps = get_model_capabilities("openai", "gpt-5.3-codex").unwrap();
995 assert_eq!(caps.context_window, Some(400_000));
996 assert_eq!(caps.max_output_tokens, Some(128_000));
997 assert!(caps.supports_adaptive_thinking);
998 assert!(caps.supports_thinking);
999 assert_eq!(caps.source_status, SourceStatus::Official);
1000 }
1001
1002 #[test]
1003 fn test_lookup_gemini_preview_models() {
1004 let flash = get_model_capabilities("gemini", "gemini-3-flash-preview").unwrap();
1005 assert_eq!(flash.context_window, Some(1_048_576));
1006 assert!(flash.supports_thinking);
1007
1008 let pro = get_model_capabilities("gemini", "gemini-3.1-pro-preview").unwrap();
1009 assert_eq!(pro.max_output_tokens, Some(65_536));
1010 assert!(pro.supports_thinking);
1011 }
1012
1013 #[test]
1014 fn test_lookup_open_reasoning_models_resolve_with_thinking() {
1015 let deepseek = get_model_capabilities("openai", "deepseek/deepseek-v4-pro").unwrap();
1017 assert!(deepseek.supports_thinking);
1018 assert_eq!(deepseek.max_output_tokens, Some(384_000));
1019 let pricing = deepseek.pricing.unwrap();
1020 assert!(pricing.input.unwrap().usd_per_million_tokens > 0.0);
1021 assert!(pricing.output.unwrap().usd_per_million_tokens > 0.0);
1022
1023 let glm = get_model_capabilities("openai", "z-ai/glm-5.1").unwrap();
1025 assert!(glm.supports_thinking);
1026 assert_eq!(glm.max_output_tokens, Some(131_072));
1027 let glm_pricing = glm.pricing.unwrap();
1028 assert!((glm_pricing.input.unwrap().usd_per_million_tokens - 0.98).abs() < f64::EPSILON);
1029 assert!((glm_pricing.output.unwrap().usd_per_million_tokens - 3.08).abs() < f64::EPSILON);
1030
1031 let kimi_thinking = get_model_capabilities("openai", "kimi-k2-thinking").unwrap();
1033 assert!(kimi_thinking.supports_thinking);
1034 assert_eq!(kimi_thinking.max_output_tokens, Some(131_072));
1035 assert!(
1036 kimi_thinking
1037 .pricing
1038 .unwrap()
1039 .output
1040 .unwrap()
1041 .usd_per_million_tokens
1042 > 0.0
1043 );
1044 }
1045
1046 #[test]
1047 fn test_lookup_open_non_reasoning_kimi_models() {
1048 let k26 = get_model_capabilities("openai", "moonshotai/kimi-k2.6").unwrap();
1050 assert!(!k26.supports_thinking);
1051 assert_eq!(k26.max_output_tokens, Some(65_536));
1052 assert!(k26.pricing.unwrap().input.unwrap().usd_per_million_tokens > 0.0);
1053
1054 let k25_native = get_model_capabilities("openai", "kimi-k2.5").unwrap();
1055 assert!(!k25_native.supports_thinking);
1056 assert_eq!(k25_native.max_output_tokens, Some(32_768));
1057 }
1058
1059 #[test]
1060 fn test_lookup_all_open_models_resolve() {
1061 for model_id in [
1064 "z-ai/glm-5.1",
1065 "glm-5",
1066 "moonshotai/kimi-k2.6",
1067 "moonshotai/kimi-k2.5",
1068 "kimi-k2.5",
1069 "kimi-k2-thinking",
1070 "deepseek/deepseek-v4-pro",
1071 "deepseek-v4-pro",
1072 "deepseek/deepseek-v4-flash",
1073 "deepseek-v4-flash",
1074 "MiniMax-M2.5",
1075 "minimax/minimax-m2.5",
1076 ] {
1077 let caps = get_model_capabilities("openai", model_id)
1078 .unwrap_or_else(|| panic!("missing capabilities for {model_id}"));
1079 assert!(
1080 caps.pricing.is_some(),
1081 "pricing should be populated for {model_id}"
1082 );
1083 assert!(
1084 caps.max_output_tokens.is_some_and(|m| m > 0),
1085 "max_output_tokens should be non-zero for {model_id}"
1086 );
1087 assert!(
1088 caps.context_window.is_some_and(|c| c > 0),
1089 "context_window should be non-zero for {model_id}"
1090 );
1091 }
1092 }
1093
1094 #[test]
1095 fn test_lookup_minimax_native_pricing() {
1096 let native = get_model_capabilities("openai", "MiniMax-M2.5").unwrap();
1097 assert!(native.supports_thinking);
1098 let pricing = native.pricing.unwrap();
1099 assert!((pricing.input.unwrap().usd_per_million_tokens - 0.3).abs() < f64::EPSILON);
1100 assert!((pricing.output.unwrap().usd_per_million_tokens - 1.2).abs() < f64::EPSILON);
1101 assert!((pricing.cached_input.unwrap().usd_per_million_tokens - 0.03).abs() < f64::EPSILON);
1104 }
1105
1106 #[test]
1107 fn test_estimate_cost_usd() {
1108 let caps = get_model_capabilities("openai", "gpt-4o").unwrap();
1109 let cost = caps
1110 .estimate_cost_usd(&Usage {
1111 input_tokens: 2_000,
1112 output_tokens: 1_000,
1113 cached_input_tokens: 0,
1114 cache_creation_input_tokens: 0,
1115 })
1116 .unwrap();
1117 assert!((cost - 0.0075).abs() < f64::EPSILON);
1118 }
1119
1120 #[test]
1121 fn test_estimate_cost_usd_with_cached_input() {
1122 let caps = get_model_capabilities("openai", "gpt-5.4").unwrap();
1123 let cost = caps
1124 .estimate_cost_usd(&Usage {
1125 input_tokens: 2_000,
1126 output_tokens: 1_000,
1127 cached_input_tokens: 1_000,
1128 cache_creation_input_tokens: 0,
1129 })
1130 .unwrap();
1131 assert!((cost - 0.01775).abs() < f64::EPSILON);
1132 }
1133}