1use anyhow::{Context, Result};
2use async_trait::async_trait;
3
4use super::{
5 CatalogEntry, ModelCatalogSource, OPENROUTER_URL, PricingTier, build_feed_client,
6 merge_band_over_base,
7};
8use crate::model_capabilities::{PricePoint, Pricing};
9
10#[derive(serde::Deserialize)]
11struct OpenRouterPricing {
12 #[serde(default)]
13 prompt: Option<String>,
14 #[serde(default)]
15 completion: Option<String>,
16 #[serde(default)]
17 input_cache_read: Option<String>,
18 #[serde(default)]
22 input_cache_write: Option<String>,
23 #[serde(default)]
26 internal_reasoning: Option<String>,
27 #[serde(default)]
31 overrides: Vec<OpenRouterPricingOverride>,
32}
33
34#[derive(serde::Deserialize)]
37struct OpenRouterPricingOverride {
38 #[serde(default)]
39 min_prompt_tokens: Option<u32>,
40 #[serde(default)]
41 prompt: Option<String>,
42 #[serde(default)]
43 completion: Option<String>,
44 #[serde(default)]
45 input_cache_read: Option<String>,
46 #[serde(default)]
47 input_cache_write: Option<String>,
48 #[serde(default)]
52 internal_reasoning: Option<String>,
53}
54
55#[derive(serde::Deserialize)]
56struct OpenRouterTopProvider {
57 #[serde(default)]
58 max_completion_tokens: Option<u32>,
59}
60
61#[derive(serde::Deserialize)]
62struct OpenRouterModel {
63 id: String,
64 #[serde(default)]
65 context_length: Option<u32>,
66 #[serde(default)]
67 pricing: Option<OpenRouterPricing>,
68 #[serde(default)]
69 top_provider: Option<OpenRouterTopProvider>,
70}
71
72#[derive(serde::Deserialize)]
73struct OpenRouterResponse {
74 #[serde(default)]
75 data: Vec<OpenRouterModel>,
76}
77
78const OPENROUTER_PROVIDER: &str = "openrouter";
81
82fn openrouter_price_per_million(value: &str) -> Option<PricePoint> {
83 let per_token: f64 = value.trim().parse().ok()?;
84 if !per_token.is_finite() || per_token <= 0.0 {
85 return None;
86 }
87 Some(PricePoint::new(per_token * 1_000_000.0))
88}
89
90pub fn parse_openrouter(json: &str) -> Result<Vec<CatalogEntry>> {
108 let parsed: OpenRouterResponse =
109 serde_json::from_str(json).context("failed to parse OpenRouter models response")?;
110 Ok(parsed
111 .data
112 .into_iter()
113 .map(|model| {
114 let base = model.pricing.as_ref().and_then(base_pricing);
115 let tiers = model.pricing.as_ref().map_or_else(
119 || Some(Vec::new()),
120 |p| tiers_from_openrouter_pricing(p, base),
121 );
122 let (pricing, pricing_tiers) =
123 tiers.map_or_else(|| (None, Vec::new()), |tiers| (base, tiers));
124 let max_output_tokens = model.top_provider.and_then(|tp| tp.max_completion_tokens);
125 CatalogEntry {
126 provider: OPENROUTER_PROVIDER.to_owned(),
127 model_id: model.id,
128 context_window: model.context_length,
129 max_output_tokens,
130 pricing,
131 pricing_tiers,
132 supports_thinking: None,
133 }
134 })
135 .collect())
136}
137
138fn base_pricing(pricing: &OpenRouterPricing) -> Option<Pricing> {
140 pricing_from_rates(
141 pricing.prompt.as_deref(),
142 pricing.completion.as_deref(),
143 pricing.input_cache_read.as_deref(),
144 pricing.input_cache_write.as_deref(),
145 pricing.internal_reasoning.as_deref(),
146 )
147}
148
149fn pricing_from_rates(
150 prompt: Option<&str>,
151 completion: Option<&str>,
152 input_cache_read: Option<&str>,
153 input_cache_write: Option<&str>,
154 internal_reasoning: Option<&str>,
155) -> Option<Pricing> {
156 let input = prompt.and_then(openrouter_price_per_million);
157 let output = completion.and_then(openrouter_price_per_million);
158 let cached_input = input_cache_read.and_then(openrouter_price_per_million);
159 let cache_write = input_cache_write.and_then(openrouter_price_per_million);
160 let reasoning = internal_reasoning.and_then(openrouter_price_per_million);
161
162 if input.is_none()
163 && output.is_none()
164 && cached_input.is_none()
165 && cache_write.is_none()
166 && reasoning.is_none()
167 {
168 return None;
169 }
170 Some(Pricing {
171 input,
172 output,
173 cached_input,
174 cache_write,
175 reasoning,
176 notes: None,
177 })
178}
179
180fn tiers_from_openrouter_pricing(
212 pricing: &OpenRouterPricing,
213 base: Option<Pricing>,
214) -> Option<Vec<PricingTier>> {
215 let mut bands: Vec<(u32, Pricing)> = Vec::with_capacity(pricing.overrides.len());
216 for band in &pricing.overrides {
217 let rates = pricing_from_rates(
220 band.prompt.as_deref(),
221 band.completion.as_deref(),
222 band.input_cache_read.as_deref(),
223 band.input_cache_write.as_deref(),
224 band.internal_reasoning.as_deref(),
225 )?;
226 if rates.input.is_none() || rates.output.is_none() {
229 return None;
230 }
231 bands.push((band.min_prompt_tokens?, rates));
234 }
235
236 let mut thresholds: Vec<u32> = bands.iter().map(|(threshold, _)| *threshold).collect();
237 thresholds.sort_unstable();
238 thresholds.dedup();
239
240 Some(
241 thresholds
242 .into_iter()
243 .filter_map(|threshold| {
244 let mut pricing = base;
247 for (min, rates) in &bands {
248 if *min <= threshold {
249 pricing = Some(merge_band_over_base(pricing, *rates));
250 }
251 }
252 pricing.map(|pricing| PricingTier {
253 min_input_tokens: threshold,
254 pricing,
255 })
256 })
257 .collect(),
258 )
259}
260
261pub struct OpenRouterSource {
263 client: reqwest::Client,
264 url: String,
265}
266
267impl Default for OpenRouterSource {
268 fn default() -> Self {
269 let client = match build_feed_client() {
270 Ok(c) => c,
271 Err(e) => {
272 log::warn!("model-catalog feed client build failed, using default client: {e}");
273 reqwest::Client::new()
274 }
275 };
276 Self {
277 client,
278 url: OPENROUTER_URL.to_owned(),
279 }
280 }
281}
282
283impl OpenRouterSource {
284 pub fn new() -> Result<Self> {
290 Ok(Self {
291 client: build_feed_client()?,
292 url: OPENROUTER_URL.to_owned(),
293 })
294 }
295
296 #[must_use]
298 pub fn with_url(mut self, url: impl Into<String>) -> Self {
299 self.url = url.into();
300 self
301 }
302}
303
304#[async_trait]
305impl ModelCatalogSource for OpenRouterSource {
306 async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
307 let body = self
308 .client
309 .get(&self.url)
310 .send()
311 .await
312 .context("OpenRouter request failed")?
313 .error_for_status()
314 .context("OpenRouter returned an error status")?
315 .text()
316 .await
317 .context("failed to read OpenRouter body")?;
318 parse_openrouter(&body)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::super::ModelRegistry;
325 use super::*;
326 use agent_sdk_foundation::llm::Usage;
327
328 struct StaticSource(Vec<CatalogEntry>);
329
330 #[async_trait]
331 impl ModelCatalogSource for StaticSource {
332 async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
333 Ok(self.0.clone())
334 }
335 }
336
337 const OPENROUTER_FIXTURE: &str = r#"{
338 "data": [
339 {
340 "id": "anthropic/claude-opus-4.8",
341 "name": "Anthropic: Claude Opus 4.8",
342 "context_length": 1000000,
343 "pricing": {
344 "prompt": "0.000005",
345 "completion": "0.000025",
346 "input_cache_read": "0.0000005"
347 },
348 "top_provider": { "max_completion_tokens": 128000 }
349 },
350 {
351 "id": "google/gemini-2.5-pro",
352 "name": "Google: Gemini 2.5 Pro",
353 "context_length": 1048576,
354 "pricing": { "prompt": "0.00000125", "completion": "0.00001" },
355 "top_provider": { "max_completion_tokens": 65536 }
356 }
357 ]
358 }"#;
359
360 const OPENROUTER_SENTINEL_FIXTURE: &str = r#"{
361 "data": [
362 {
363 "id": "openrouter/auto",
364 "name": "Auto Router",
365 "context_length": 2000000,
366 "pricing": {
367 "prompt": "-1",
368 "completion": "-1",
369 "input_cache_read": "-1"
370 }
371 }
372 ]
373 }"#;
374
375 fn find<'a>(
376 entries: &'a [CatalogEntry],
377 provider: &str,
378 model: &str,
379 ) -> Result<&'a CatalogEntry> {
380 entries
381 .iter()
382 .find(|e| e.provider == provider && e.model_id == model)
383 .with_context(|| format!("missing {provider}/{model}"))
384 }
385
386 #[test]
387 fn parse_openrouter_converts_per_token_to_per_million_and_keys_rows_by_route() -> Result<()> {
388 let entries = parse_openrouter(OPENROUTER_FIXTURE)?;
389 assert_eq!(entries.len(), 2);
390
391 assert!(entries.iter().all(|e| e.provider == "openrouter"));
395 assert!(
396 !entries
397 .iter()
398 .any(|e| e.provider == "anthropic" || e.provider == "gemini"),
399 );
400
401 let opus = find(&entries, "openrouter", "anthropic/claude-opus-4.8")?;
402 assert_eq!(opus.context_window, Some(1_000_000));
403 assert_eq!(opus.max_output_tokens, Some(128_000));
404 let pricing = opus.pricing.context("opus pricing missing")?;
405 assert!(
407 (pricing.input.context("input")?.usd_per_million_tokens - 5.0).abs() < f64::EPSILON
408 );
409 assert!(
410 (pricing.output.context("output")?.usd_per_million_tokens - 25.0).abs() < f64::EPSILON
411 );
412 assert!(
413 (pricing
414 .cached_input
415 .context("cache")?
416 .usd_per_million_tokens
417 - 0.5)
418 .abs()
419 < f64::EPSILON
420 );
421
422 let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
423 assert_eq!(gemini.context_window, Some(1_048_576));
424 Ok(())
425 }
426
427 #[tokio::test]
428 async fn parse_openrouter_treats_minus_one_sentinel_prices_as_absent() -> Result<()> {
429 let entries = parse_openrouter(OPENROUTER_SENTINEL_FIXTURE)?;
430 assert_eq!(entries.len(), 1);
431
432 let auto = find(&entries, "openrouter", "openrouter/auto")?;
433 assert!(
434 auto.pricing.is_none(),
435 "sentinel `-1` prices must yield None pricing, got {:?}",
436 auto.pricing
437 );
438 assert_eq!(auto.context_window, Some(2_000_000));
439
440 let registry = ModelRegistry::new();
441 registry.refresh(&StaticSource(entries)).await?;
442 let usage = Usage {
443 input_tokens: 1_000,
444 output_tokens: 1_000,
445 cached_input_tokens: 0,
446 cache_creation_input_tokens: 0,
447 };
448 assert_eq!(
449 registry.estimate_cost_usd("openrouter", "openrouter/auto", &usage),
450 None
451 );
452 Ok(())
453 }
454
455 const OPENROUTER_OVERRIDES_FIXTURE: &str = r#"{
459 "data": [
460 {
461 "id": "google/gemini-2.5-pro",
462 "context_length": 1048576,
463 "pricing": {
464 "prompt": "0.00000125",
465 "completion": "0.00001",
466 "input_cache_read": "0.000000125",
467 "input_cache_write": "0.000000375",
468 "overrides": [
469 {
470 "min_prompt_tokens": 200000,
471 "prompt": "0.0000025",
472 "completion": "0.000015",
473 "input_cache_read": "0.00000025"
474 }
475 ]
476 }
477 },
478 {
479 "id": "openai/gpt-5.6-luna",
480 "context_length": 400000,
481 "pricing": {
482 "prompt": "0.000001",
483 "completion": "0.000006",
484 "input_cache_read": "0.0000001",
485 "input_cache_write": "0.00000125",
486 "overrides": [
487 {
488 "min_prompt_tokens": 272000,
489 "prompt": "0.000002",
490 "completion": "0.000009",
491 "input_cache_read": "0.0000002",
492 "input_cache_write": "0.0000025"
493 }
494 ]
495 }
496 }
497 ]
498 }"#;
499
500 #[test]
501 fn parse_openrouter_reads_long_context_overrides() -> Result<()> {
502 let entries = parse_openrouter(OPENROUTER_OVERRIDES_FIXTURE)?;
503
504 let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
505 assert_eq!(gemini.pricing_tiers.len(), 1);
506 let band = gemini.pricing_tiers[0];
507 assert_eq!(band.min_input_tokens, 200_000);
509 assert!(
510 (band.pricing.input.context("input")?.usd_per_million_tokens - 2.5).abs()
511 < f64::EPSILON
512 );
513 assert!(
514 (band
515 .pricing
516 .output
517 .context("output")?
518 .usd_per_million_tokens
519 - 15.0)
520 .abs()
521 < f64::EPSILON
522 );
523 assert!(
526 (band
527 .pricing
528 .cache_write
529 .context("cache_write inherited from base")?
530 .usd_per_million_tokens
531 - 0.375)
532 .abs()
533 < f64::EPSILON
534 );
535 Ok(())
536 }
537
538 #[tokio::test]
539 async fn long_context_route_bills_at_the_override_rate() -> Result<()> {
540 let registry = ModelRegistry::new();
541 registry
542 .refresh(&StaticSource(parse_openrouter(
543 OPENROUTER_OVERRIDES_FIXTURE,
544 )?))
545 .await?;
546
547 let short = Usage {
549 input_tokens: 100_000,
550 output_tokens: 100_000,
551 cached_input_tokens: 0,
552 cache_creation_input_tokens: 0,
553 };
554 let short_cost = registry
555 .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &short)
556 .context("cost estimate missing")?;
557 assert!(
558 (short_cost - 1.125).abs() < 1e-9,
559 "unexpected cost: {short_cost}"
560 );
561
562 for input_tokens in [200_000, 300_000] {
566 let long = Usage {
567 input_tokens,
568 output_tokens: 100_000,
569 cached_input_tokens: 0,
570 cache_creation_input_tokens: 0,
571 };
572 let cost = registry
573 .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &long)
574 .context("cost estimate missing")?;
575 let expected = (f64::from(input_tokens) / 1_000_000.0).mul_add(2.5, 1.5);
576 assert!(
577 (cost - expected).abs() < 1e-9,
578 "unexpected cost at {input_tokens}: {cost}"
579 );
580 }
581
582 let just_under = Usage {
584 input_tokens: 199_999,
585 output_tokens: 0,
586 cached_input_tokens: 0,
587 cache_creation_input_tokens: 0,
588 };
589 let under_cost = registry
590 .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &just_under)
591 .context("cost estimate missing")?;
592 assert!(
593 (under_cost - 0.249_998_75).abs() < 1e-9,
594 "unexpected cost: {under_cost}"
595 );
596 Ok(())
597 }
598
599 #[tokio::test]
603 async fn aggregate_repricing_ignores_openrouter_overrides() -> Result<()> {
604 let registry = ModelRegistry::new();
605 registry
606 .refresh(&StaticSource(parse_openrouter(
607 OPENROUTER_OVERRIDES_FIXTURE,
608 )?))
609 .await?;
610
611 let aggregate = Usage {
613 input_tokens: 300_000,
614 output_tokens: 0,
615 cached_input_tokens: 0,
616 cache_creation_input_tokens: 0,
617 };
618 let base = registry
619 .estimate_dynamic_base_cost_usd("openrouter", "google/gemini-2.5-pro", &aggregate)
620 .context("cost estimate missing")?;
621 assert!((base - 0.375).abs() < 1e-9, "unexpected cost: {base}");
623 Ok(())
624 }
625
626 #[tokio::test]
630 async fn non_monotonic_overrides_fold_later_wins() -> Result<()> {
631 const NON_MONOTONIC_FIXTURE: &str = r#"{
634 "data": [
635 {
636 "id": "vendor/model",
637 "pricing": {
638 "prompt": "0.000001",
639 "completion": "0.000001",
640 "overrides": [
641 { "min_prompt_tokens": 200000, "prompt": "0.000005", "completion": "0.000005" },
642 { "min_prompt_tokens": 100000, "prompt": "0.000003", "completion": "0.000003" }
643 ]
644 }
645 }
646 ]
647 }"#;
648
649 let entries = parse_openrouter(NON_MONOTONIC_FIXTURE)?;
650 let model = find(&entries, "openrouter", "vendor/model")?;
651 assert_eq!(model.pricing_tiers.len(), 2);
653
654 let registry = ModelRegistry::new();
655 registry.refresh(&StaticSource(entries)).await?;
656 let out = |n: u32| Usage {
657 input_tokens: n,
658 output_tokens: 0,
659 cached_input_tokens: 0,
660 cache_creation_input_tokens: 0,
661 };
662
663 let base = registry
665 .estimate_cost_usd("openrouter", "vendor/model", &out(50_000))
666 .context("cost estimate missing")?;
667 assert!((base - 0.05).abs() < 1e-9, "unexpected cost: {base}");
668
669 let mid = registry
671 .estimate_cost_usd("openrouter", "vendor/model", &out(150_000))
672 .context("cost estimate missing")?;
673 assert!((mid - 0.45).abs() < 1e-9, "unexpected cost: {mid}");
674
675 let high = registry
679 .estimate_cost_usd("openrouter", "vendor/model", &out(250_000))
680 .context("cost estimate missing")?;
681 assert!((high - 0.75).abs() < 1e-9, "unexpected cost: {high}");
682 Ok(())
683 }
684
685 #[test]
687 fn parse_openrouter_survives_a_drifted_override() -> Result<()> {
688 const DRIFTED_FIXTURE: &str = r#"{
689 "data": [
690 {
691 "id": "vendor/healthy",
692 "pricing": { "prompt": "0.000001", "completion": "0.000002" }
693 },
694 {
695 "id": "vendor/no-threshold",
696 "pricing": {
697 "prompt": "0.000001",
698 "completion": "0.000002",
699 "overrides": [{ "prompt": "0.000009", "completion": "0.000009" }]
700 }
701 },
702 {
703 "id": "vendor/no-rates",
704 "pricing": {
705 "prompt": "0.000001",
706 "completion": "0.000002",
707 "overrides": [{ "min_prompt_tokens": 200000, "input_cache_read": "0.0000001" }]
708 }
709 }
710 ]
711 }"#;
712
713 let entries = parse_openrouter(DRIFTED_FIXTURE)?;
714 assert_eq!(
715 entries.len(),
716 3,
717 "the parse must not abort on a drifted row"
718 );
719
720 let healthy = find(&entries, "openrouter", "vendor/healthy")?;
721 assert!(healthy.pricing.is_some());
722 assert!(healthy.pricing_tiers.is_empty());
723
724 for model in ["vendor/no-threshold", "vendor/no-rates"] {
728 let drifted = find(&entries, "openrouter", model)?;
729 assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
730 assert!(drifted.pricing_tiers.is_empty());
731 }
732 Ok(())
733 }
734}