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 served_speed: None,
444 input_tokens: 1_000,
445 output_tokens: 1_000,
446 cached_input_tokens: 0,
447 cache_creation_input_tokens: 0,
448 };
449 assert_eq!(
450 registry.estimate_cost_usd("openrouter", "openrouter/auto", &usage),
451 None
452 );
453 Ok(())
454 }
455
456 const OPENROUTER_OVERRIDES_FIXTURE: &str = r#"{
460 "data": [
461 {
462 "id": "google/gemini-2.5-pro",
463 "context_length": 1048576,
464 "pricing": {
465 "prompt": "0.00000125",
466 "completion": "0.00001",
467 "input_cache_read": "0.000000125",
468 "input_cache_write": "0.000000375",
469 "overrides": [
470 {
471 "min_prompt_tokens": 200000,
472 "prompt": "0.0000025",
473 "completion": "0.000015",
474 "input_cache_read": "0.00000025"
475 }
476 ]
477 }
478 },
479 {
480 "id": "openai/gpt-5.6-luna",
481 "context_length": 400000,
482 "pricing": {
483 "prompt": "0.000001",
484 "completion": "0.000006",
485 "input_cache_read": "0.0000001",
486 "input_cache_write": "0.00000125",
487 "overrides": [
488 {
489 "min_prompt_tokens": 272000,
490 "prompt": "0.000002",
491 "completion": "0.000009",
492 "input_cache_read": "0.0000002",
493 "input_cache_write": "0.0000025"
494 }
495 ]
496 }
497 }
498 ]
499 }"#;
500
501 #[test]
502 fn parse_openrouter_reads_long_context_overrides() -> Result<()> {
503 let entries = parse_openrouter(OPENROUTER_OVERRIDES_FIXTURE)?;
504
505 let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
506 assert_eq!(gemini.pricing_tiers.len(), 1);
507 let band = gemini.pricing_tiers[0];
508 assert_eq!(band.min_input_tokens, 200_000);
510 assert!(
511 (band.pricing.input.context("input")?.usd_per_million_tokens - 2.5).abs()
512 < f64::EPSILON
513 );
514 assert!(
515 (band
516 .pricing
517 .output
518 .context("output")?
519 .usd_per_million_tokens
520 - 15.0)
521 .abs()
522 < f64::EPSILON
523 );
524 assert!(
527 (band
528 .pricing
529 .cache_write
530 .context("cache_write inherited from base")?
531 .usd_per_million_tokens
532 - 0.375)
533 .abs()
534 < f64::EPSILON
535 );
536 Ok(())
537 }
538
539 #[tokio::test]
540 async fn long_context_route_bills_at_the_override_rate() -> Result<()> {
541 let registry = ModelRegistry::new();
542 registry
543 .refresh(&StaticSource(parse_openrouter(
544 OPENROUTER_OVERRIDES_FIXTURE,
545 )?))
546 .await?;
547
548 let short = Usage {
550 served_speed: None,
551 input_tokens: 100_000,
552 output_tokens: 100_000,
553 cached_input_tokens: 0,
554 cache_creation_input_tokens: 0,
555 };
556 let short_cost = registry
557 .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &short)
558 .context("cost estimate missing")?;
559 assert!(
560 (short_cost - 1.125).abs() < 1e-9,
561 "unexpected cost: {short_cost}"
562 );
563
564 for input_tokens in [200_000, 300_000] {
568 let long = Usage {
569 served_speed: None,
570 input_tokens,
571 output_tokens: 100_000,
572 cached_input_tokens: 0,
573 cache_creation_input_tokens: 0,
574 };
575 let cost = registry
576 .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &long)
577 .context("cost estimate missing")?;
578 let expected = (f64::from(input_tokens) / 1_000_000.0).mul_add(2.5, 1.5);
579 assert!(
580 (cost - expected).abs() < 1e-9,
581 "unexpected cost at {input_tokens}: {cost}"
582 );
583 }
584
585 let just_under = Usage {
587 served_speed: None,
588 input_tokens: 199_999,
589 output_tokens: 0,
590 cached_input_tokens: 0,
591 cache_creation_input_tokens: 0,
592 };
593 let under_cost = registry
594 .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &just_under)
595 .context("cost estimate missing")?;
596 assert!(
597 (under_cost - 0.249_998_75).abs() < 1e-9,
598 "unexpected cost: {under_cost}"
599 );
600 Ok(())
601 }
602
603 #[tokio::test]
607 async fn aggregate_repricing_ignores_openrouter_overrides() -> Result<()> {
608 let registry = ModelRegistry::new();
609 registry
610 .refresh(&StaticSource(parse_openrouter(
611 OPENROUTER_OVERRIDES_FIXTURE,
612 )?))
613 .await?;
614
615 let aggregate = Usage {
617 served_speed: None,
618 input_tokens: 300_000,
619 output_tokens: 0,
620 cached_input_tokens: 0,
621 cache_creation_input_tokens: 0,
622 };
623 let base = registry
624 .estimate_dynamic_base_cost_usd("openrouter", "google/gemini-2.5-pro", &aggregate)
625 .context("cost estimate missing")?;
626 assert!((base - 0.375).abs() < 1e-9, "unexpected cost: {base}");
628 Ok(())
629 }
630
631 #[tokio::test]
635 async fn non_monotonic_overrides_fold_later_wins() -> Result<()> {
636 const NON_MONOTONIC_FIXTURE: &str = r#"{
639 "data": [
640 {
641 "id": "vendor/model",
642 "pricing": {
643 "prompt": "0.000001",
644 "completion": "0.000001",
645 "overrides": [
646 { "min_prompt_tokens": 200000, "prompt": "0.000005", "completion": "0.000005" },
647 { "min_prompt_tokens": 100000, "prompt": "0.000003", "completion": "0.000003" }
648 ]
649 }
650 }
651 ]
652 }"#;
653
654 let entries = parse_openrouter(NON_MONOTONIC_FIXTURE)?;
655 let model = find(&entries, "openrouter", "vendor/model")?;
656 assert_eq!(model.pricing_tiers.len(), 2);
658
659 let registry = ModelRegistry::new();
660 registry.refresh(&StaticSource(entries)).await?;
661 let out = |n: u32| Usage {
662 served_speed: None,
663 input_tokens: n,
664 output_tokens: 0,
665 cached_input_tokens: 0,
666 cache_creation_input_tokens: 0,
667 };
668
669 let base = registry
671 .estimate_cost_usd("openrouter", "vendor/model", &out(50_000))
672 .context("cost estimate missing")?;
673 assert!((base - 0.05).abs() < 1e-9, "unexpected cost: {base}");
674
675 let mid = registry
677 .estimate_cost_usd("openrouter", "vendor/model", &out(150_000))
678 .context("cost estimate missing")?;
679 assert!((mid - 0.45).abs() < 1e-9, "unexpected cost: {mid}");
680
681 let high = registry
685 .estimate_cost_usd("openrouter", "vendor/model", &out(250_000))
686 .context("cost estimate missing")?;
687 assert!((high - 0.75).abs() < 1e-9, "unexpected cost: {high}");
688 Ok(())
689 }
690
691 #[test]
693 fn parse_openrouter_survives_a_drifted_override() -> Result<()> {
694 const DRIFTED_FIXTURE: &str = r#"{
695 "data": [
696 {
697 "id": "vendor/healthy",
698 "pricing": { "prompt": "0.000001", "completion": "0.000002" }
699 },
700 {
701 "id": "vendor/no-threshold",
702 "pricing": {
703 "prompt": "0.000001",
704 "completion": "0.000002",
705 "overrides": [{ "prompt": "0.000009", "completion": "0.000009" }]
706 }
707 },
708 {
709 "id": "vendor/no-rates",
710 "pricing": {
711 "prompt": "0.000001",
712 "completion": "0.000002",
713 "overrides": [{ "min_prompt_tokens": 200000, "input_cache_read": "0.0000001" }]
714 }
715 }
716 ]
717 }"#;
718
719 let entries = parse_openrouter(DRIFTED_FIXTURE)?;
720 assert_eq!(
721 entries.len(),
722 3,
723 "the parse must not abort on a drifted row"
724 );
725
726 let healthy = find(&entries, "openrouter", "vendor/healthy")?;
727 assert!(healthy.pricing.is_some());
728 assert!(healthy.pricing_tiers.is_empty());
729
730 for model in ["vendor/no-threshold", "vendor/no-rates"] {
734 let drifted = find(&entries, "openrouter", model)?;
735 assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
736 assert!(drifted.pricing_tiers.is_empty());
737 }
738 Ok(())
739 }
740}