1use std::collections::HashMap;
20
21use crate::scenario::{EndpointConfig, Provider};
22
23#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ModelPricing {
26 pub input_per_1m: f64,
28 pub output_per_1m: f64,
30 pub cached_input_per_1m: Option<f64>,
32 pub cache_write_per_1m: Option<f64>,
34}
35
36#[must_use]
43pub fn parse_openrouter_models(json: &serde_json::Value, model: &str) -> Option<ModelPricing> {
44 let wanted = model.to_ascii_lowercase();
45 let leaf = wanted.rsplit('/').next().unwrap_or(wanted.as_str());
46 let data = json["data"].as_array()?;
47 let entry = data.iter().find(|m| {
48 let id = m["id"].as_str().unwrap_or_default().to_ascii_lowercase();
49 id == wanted || id.rsplit('/').next().unwrap_or_default() == leaf
50 })?;
51 let pricing = &entry["pricing"];
52 Some(ModelPricing {
53 input_per_1m: per_million(pricing, "prompt")?,
54 output_per_1m: per_million(pricing, "completion")?,
55 cached_input_per_1m: per_million(pricing, "input_cache_read"),
56 cache_write_per_1m: per_million(pricing, "input_cache_write"),
57 })
58}
59
60fn per_million(pricing: &serde_json::Value, key: &str) -> Option<f64> {
64 let value = match &pricing[key] {
65 serde_json::Value::String(s) => s.parse::<f64>().ok()?,
66 serde_json::Value::Number(n) => n.as_f64()?,
67 _ => return None,
68 };
69 (value >= 0.0).then_some(value * 1_000_000.0)
70}
71
72pub async fn fetch_openrouter_pricing(
79 client: &reqwest::Client,
80 model: &str,
81) -> Result<ModelPricing, String> {
82 let json = fetch_openrouter_models(client).await?;
83 parse_openrouter_models(&json, model)
84 .ok_or_else(|| format!("model `{model}` not found in OpenRouter pricing"))
85}
86
87async fn fetch_openrouter_models(client: &reqwest::Client) -> Result<serde_json::Value, String> {
94 let resp = client
95 .get("https://openrouter.ai/api/v1/models")
96 .send()
97 .await
98 .map_err(|e| format!("OpenRouter models request failed: {e}"))?;
99 if !resp.status().is_success() {
100 return Err(format!(
101 "OpenRouter models request returned HTTP {}",
102 resp.status()
103 ));
104 }
105 resp.json()
106 .await
107 .map_err(|e| format!("OpenRouter models response was not JSON: {e}"))
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum Source {
113 OpenRouter,
115 Bedrock,
117}
118
119#[must_use]
127fn source_for(ec: &EndpointConfig) -> Option<Source> {
128 let source = ec.pricing_source.as_deref().map(str::to_ascii_lowercase);
129 match source.as_deref() {
130 Some("openrouter") => Some(Source::OpenRouter),
131 Some("bedrock") => Some(Source::Bedrock),
132 Some("off" | "none" | "disabled") => None,
133 _ => auto_source(ec),
134 }
135}
136
137#[must_use]
140fn auto_source(ec: &EndpointConfig) -> Option<Source> {
141 if ec.provider == Provider::Bedrock {
142 Some(Source::Bedrock)
143 } else if ec
144 .url
145 .as_deref()
146 .is_some_and(|url| url.to_ascii_lowercase().contains("openrouter.ai"))
147 {
148 Some(Source::OpenRouter)
149 } else {
150 None
151 }
152}
153
154pub fn apply_pricing(ec: &mut EndpointConfig, pricing: &ModelPricing) {
157 let p = ec.pricing.get_or_insert_with(Default::default);
158 if p.input_per_1m_tokens == 0.0 {
159 p.input_per_1m_tokens = pricing.input_per_1m;
160 }
161 if p.output_per_1m_tokens == 0.0 {
162 p.output_per_1m_tokens = pricing.output_per_1m;
163 }
164 if p.cached_input_per_1m_tokens.is_none() {
165 p.cached_input_per_1m_tokens = pricing.cached_input_per_1m;
166 }
167 if p.cache_write_per_1m_tokens.is_none() {
168 p.cache_write_per_1m_tokens = pricing.cache_write_per_1m;
169 }
170}
171
172#[allow(clippy::implicit_hasher, clippy::too_many_lines)]
184pub async fn apply_auto_pricing(
185 endpoints: &mut HashMap<String, EndpointConfig>,
186 client: &reqwest::Client,
187) -> Result<usize, String> {
188 let mut priced = 0;
189 let mut first_error: Option<String> = None;
190 let mut bedrock_catalogs: HashMap<String, HashMap<String, ModelPricing>> = HashMap::new();
192 let mut openrouter_catalog: Option<serde_json::Value> = None;
193 for ec in endpoints.values_mut() {
194 let Some(model) = ec.model.clone() else {
195 continue;
196 };
197 let result = match source_for(ec) {
198 Some(Source::OpenRouter) => {
199 if openrouter_catalog.is_none() {
200 match fetch_openrouter_models(client).await {
201 Ok(catalog) => openrouter_catalog = Some(catalog),
202 Err(e) => {
203 first_error.get_or_insert(e);
204 continue;
205 }
206 }
207 }
208 let catalog = openrouter_catalog.as_ref().expect("catalog fetched above");
209 parse_openrouter_models(catalog, &model)
210 .ok_or_else(|| format!("model `{model}` not found in OpenRouter pricing"))
211 }
212 Some(Source::Bedrock) => {
213 let region = bedrock_region(ec);
214 if !bedrock_catalogs.contains_key(®ion) {
215 match fetch_bedrock_catalog(client, ®ion).await {
216 Ok(catalog) => {
217 bedrock_catalogs.insert(region.clone(), catalog);
218 }
219 Err(e) => {
220 first_error.get_or_insert(e);
221 continue;
222 }
223 }
224 }
225 let catalog = &bedrock_catalogs[®ion];
226 lookup_bedrock(catalog, &model)
227 .ok_or_else(|| format!("model `{model}` not found in Bedrock pricing"))
228 }
229 None => continue,
230 };
231 match result {
232 Ok(pricing) => {
233 apply_pricing(ec, &pricing);
234 priced += 1;
235 }
236 Err(e) => {
237 first_error.get_or_insert(e);
238 }
239 }
240 }
241 match first_error {
242 Some(e) if priced == 0 => Err(e),
243 _ => Ok(priced),
244 }
245}
246
247#[must_use]
251fn bedrock_region(ec: &EndpointConfig) -> String {
252 ec.aws
253 .region
254 .clone()
255 .or_else(|| std::env::var("AWS_REGION").ok())
256 .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
257 .filter(|r| !r.trim().is_empty())
258 .unwrap_or_else(|| "us-east-1".to_owned())
259}
260
261const PRICING_HOST: &str = "https://pricing.us-east-1.amazonaws.com";
263
264const BEDROCK_OFFERS: [&str; 2] = ["AmazonBedrock", "AmazonBedrockFoundationModels"];
267
268async fn fetch_bedrock_catalog(
276 client: &reqwest::Client,
277 region: &str,
278) -> Result<HashMap<String, ModelPricing>, String> {
279 let mut acc: HashMap<String, Partial> = HashMap::new();
280 for offer in BEDROCK_OFFERS {
281 let doc = fetch_offer_region(client, offer, region).await?;
282 let overwrite = offer == "AmazonBedrockFoundationModels";
285 parse_bedrock_offer(&doc, &mut acc, overwrite);
286 }
287 Ok(acc
288 .into_iter()
289 .filter_map(|(key, partial)| partial.finish().map(|p| (key, p)))
290 .collect())
291}
292
293async fn fetch_offer_region(
295 client: &reqwest::Client,
296 offer: &str,
297 region: &str,
298) -> Result<serde_json::Value, String> {
299 let index_url = format!("{PRICING_HOST}/offers/v1.0/aws/{offer}/current/region_index.json");
300 let index: serde_json::Value = client
301 .get(&index_url)
302 .send()
303 .await
304 .map_err(|e| format!("{offer} region index request failed: {e}"))?
305 .json()
306 .await
307 .map_err(|e| format!("{offer} region index was not JSON: {e}"))?;
308 let path = index["regions"][region]["currentVersionUrl"]
309 .as_str()
310 .ok_or_else(|| format!("region `{region}` not present in {offer} price list"))?;
311 let url = format!("{PRICING_HOST}{path}");
312 client
313 .get(&url)
314 .send()
315 .await
316 .map_err(|e| format!("{offer} price list request failed: {e}"))?
317 .json()
318 .await
319 .map_err(|e| format!("{offer} price list was not JSON: {e}"))
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324enum Role {
325 Input,
327 Output,
329 Read,
331 Write,
333}
334
335#[derive(Debug, Default, Clone, Copy)]
337struct Partial {
338 input: Option<f64>,
339 output: Option<f64>,
340 read: Option<f64>,
341 write: Option<f64>,
342}
343
344impl Partial {
345 const fn set(&mut self, role: Role, value: f64, overwrite: bool) {
346 let slot = match role {
347 Role::Input => &mut self.input,
348 Role::Output => &mut self.output,
349 Role::Read => &mut self.read,
350 Role::Write => &mut self.write,
351 };
352 if overwrite || slot.is_none() {
353 *slot = Some(value);
354 }
355 }
356
357 fn finish(self) -> Option<ModelPricing> {
358 Some(ModelPricing {
359 input_per_1m: self.input?,
360 output_per_1m: self.output?,
361 cached_input_per_1m: self.read,
362 cache_write_per_1m: self.write,
363 })
364 }
365}
366
367fn parse_bedrock_offer(
369 doc: &serde_json::Value,
370 acc: &mut HashMap<String, Partial>,
371 overwrite: bool,
372) {
373 let Some(products) = doc["products"].as_object() else {
374 return;
375 };
376 for (sku, product) in products {
377 let a = &product["attributes"];
378 let Some((model, role)) = bedrock_entry(a) else {
379 continue;
380 };
381 let Some((usd, unit)) = on_demand_price(doc, sku) else {
382 continue;
383 };
384 let Some(per_million) = to_per_million(usd, &unit) else {
385 continue;
386 };
387 acc.entry(compact_key(model))
388 .or_default()
389 .set(role, per_million, overwrite);
390 }
391}
392
393fn bedrock_entry(attrs: &serde_json::Value) -> Option<(&str, Role)> {
397 if let Some(model) = attrs["model"].as_str().filter(|m| !m.is_empty()) {
400 if attrs["feature"].as_str() != Some("On-demand Inference") {
401 return None;
402 }
403 if attrs["batch"].as_str().is_some_and(|b| !b.is_empty()) {
404 return None;
405 }
406 let role = offer1_role(attrs["inferenceType"].as_str().unwrap_or_default())?;
407 return Some((model, role));
408 }
409 if let Some(servicename) = attrs["servicename"].as_str() {
412 if let Some(role) = offer2_role(attrs["usagetype"].as_str().unwrap_or_default()) {
413 let model = servicename
414 .strip_suffix(" (Amazon Bedrock Edition)")
415 .unwrap_or(servicename);
416 return Some((model, role));
417 }
418 }
419 None
420}
421
422fn normalize_token(value: &str) -> String {
424 value
425 .chars()
426 .filter(char::is_ascii_alphanumeric)
427 .collect::<String>()
428 .to_ascii_lowercase()
429}
430
431fn offer1_role(inference_type: &str) -> Option<Role> {
433 let s = normalize_token(inference_type);
434 if ["priority", "flex", "batch", "global", "latency"]
435 .iter()
436 .any(|t| s.contains(t))
437 {
438 return None;
439 }
440 if s.contains("cacheread") {
441 Some(Role::Read)
442 } else if s.contains("cachewrite") {
443 Some(Role::Write)
444 } else if s.contains("input")
445 && !s.contains("image")
446 && !s.contains("video")
447 && !s.contains("audio")
448 {
449 Some(Role::Input)
450 } else if s.contains("output") && !s.contains("image") && !s.contains("video") {
451 Some(Role::Output)
452 } else {
453 None
454 }
455}
456
457fn offer2_role(usagetype: &str) -> Option<Role> {
460 let s = normalize_token(usagetype);
461 if [
462 "global", "batch", "priority", "flex", "latency", "1h", "30m", "custom",
463 ]
464 .iter()
465 .any(|t| s.contains(t))
466 {
467 return None;
468 }
469 if s.contains("cacheread") {
470 Some(Role::Read)
471 } else if s.contains("cachewrite") {
472 Some(Role::Write)
473 } else if s.contains("inputtoken") {
474 Some(Role::Input)
475 } else if s.contains("outputtoken") {
476 Some(Role::Output)
477 } else {
478 None
479 }
480}
481
482fn on_demand_price(doc: &serde_json::Value, sku: &str) -> Option<(f64, String)> {
484 let offers = doc["terms"]["OnDemand"][sku].as_object()?;
485 let offer = offers.values().next()?;
486 let dimension = offer["priceDimensions"].as_object()?.values().next()?;
487 let usd = dimension["pricePerUnit"]["USD"]
488 .as_str()?
489 .parse::<f64>()
490 .ok()?;
491 let unit = dimension["unit"].as_str().unwrap_or_default().to_owned();
492 Some((usd, unit))
493}
494
495fn to_per_million(price: f64, unit: &str) -> Option<f64> {
497 let unit = unit.to_ascii_lowercase();
498 if unit.contains("1m") || unit.contains("million") {
499 Some(price)
500 } else if unit.contains("1k") || unit.contains("thousand") {
501 Some(price * 1_000.0)
502 } else if unit.contains("token") {
503 Some(price * 1_000_000.0)
504 } else {
505 None
506 }
507}
508
509const MODEL_PREFIXES: [&str; 12] = [
511 "us", "eu", "apac", "global", "us-gov", "ca", "sa", "me", "af", "il", "ap", "gov",
512];
513
514const MODEL_PROVIDERS: [&str; 21] = [
516 "anthropic",
517 "amazon",
518 "meta",
519 "mistral",
520 "cohere",
521 "ai21",
522 "stability",
523 "deepseek",
524 "openai",
525 "google",
526 "qwen",
527 "writer",
528 "nvidia",
529 "minimax",
530 "moonshot",
531 "moonshotai",
532 "zai",
533 "xai",
534 "kimi",
535 "twelvelabs",
536 "luma",
537];
538
539#[must_use]
546fn model_key(model: &str) -> String {
547 let lower = model.to_ascii_lowercase();
548 let no_revision = lower.split(':').next().unwrap_or(&lower);
549 let kept: Vec<&str> = no_revision
550 .split('.')
551 .filter(|s| !MODEL_PREFIXES.contains(s) && !MODEL_PROVIDERS.contains(s))
552 .collect();
553 let joined = if kept.is_empty() {
554 no_revision
555 } else {
556 &kept.join("-")
557 };
558 compact_key(joined)
559}
560
561#[must_use]
563fn compact_key(value: &str) -> String {
564 let mut out = String::new();
565 for token in value.split(['-', '_', ' ']).filter(|t| !t.is_empty()) {
566 if token.len() == 8 && token.chars().all(|c| c.is_ascii_digit()) {
567 continue;
568 }
569 out.push_str(token);
570 }
571 out.chars()
572 .filter(char::is_ascii_alphanumeric)
573 .collect::<String>()
574 .to_ascii_lowercase()
575}
576
577#[must_use]
580fn lookup_bedrock(catalog: &HashMap<String, ModelPricing>, model: &str) -> Option<ModelPricing> {
581 let key = model_key(model);
582 if let Some(p) = catalog.get(&key) {
583 return Some(*p);
584 }
585 if let Some(p) = prefix_lookup(catalog, &key) {
586 return Some(p);
587 }
588 if let Some(stripped) = strip_trailing_version(&key) {
589 if let Some(p) = catalog.get(&stripped) {
590 return Some(*p);
591 }
592 if let Some(p) = prefix_lookup(catalog, &stripped) {
593 return Some(p);
594 }
595 }
596 None
597}
598
599fn prefix_lookup(catalog: &HashMap<String, ModelPricing>, key: &str) -> Option<ModelPricing> {
601 let mut keys: Vec<&String> = catalog.keys().collect();
602 keys.sort();
603 let mut best: Option<(&String, usize)> = None;
604 for candidate in keys {
605 if key.starts_with(candidate.as_str()) || candidate.starts_with(key) {
606 let diff = candidate.len().abs_diff(key.len());
607 if best.as_ref().is_none_or(|(_, d)| diff < *d) {
608 best = Some((candidate, diff));
609 }
610 }
611 }
612 best.and_then(|(k, _)| catalog.get(k).copied())
613}
614
615fn strip_trailing_version(key: &str) -> Option<String> {
617 let idx = key.rfind('v')?;
618 let rest = &key[idx + 1..];
619 if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
620 Some(key[..idx].to_owned())
621 } else {
622 None
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::{
629 apply_pricing, lookup_bedrock, model_key, parse_bedrock_offer, parse_openrouter_models,
630 to_per_million, Partial,
631 };
632 use crate::scenario::{EndpointConfig, PricingConfig};
633 use std::collections::HashMap;
634
635 fn bedrock_catalog() -> HashMap<String, super::ModelPricing> {
637 let offer1 = serde_json::json!({
638 "products": {
639 "s1": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
640 "inferenceType": "Input tokens", "batch": ""}},
641 "s2": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
642 "inferenceType": "Output tokens", "batch": ""}},
643 "s3": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
644 "inferenceType": "Input tokens flex", "batch": ""}},
645 "s4": {"attributes": {"model": "Nova Lite", "feature": "Batch Inference",
646 "inferenceType": "Input tokens", "batch": "true"}}
647 },
648 "terms": {"OnDemand": {
649 "s1": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
650 "pricePerUnit": {"USD": "0.00006"}}}}},
651 "s2": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
652 "pricePerUnit": {"USD": "0.00024"}}}}},
653 "s3": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
654 "pricePerUnit": {"USD": "0.00005"}}}}},
655 "s4": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
656 "pricePerUnit": {"USD": "0.00003"}}}}}
657 }}
658 });
659 let offer2 = serde_json::json!({
660 "products": {
661 "c1": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
662 "usagetype": "USE1-MP:USE1_InputTokenCount-Units"}},
663 "c2": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
664 "usagetype": "USE1-MP:USE1_OutputTokenCount-Units"}},
665 "c3": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
666 "usagetype": "USE1-MP:USE1_CacheReadInputTokenCount-Units"}},
667 "c4": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
668 "usagetype": "USE1-MP:USE1_CacheWriteInputTokenCount-Units"}},
669 "c5": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
670 "usagetype": "USE1-MP:USE1_CacheWrite1hInputTokenCount-Units"}},
671 "c6": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
672 "usagetype": "USE1-MP:USE1_InputTokenCount_Global-Units"}}
673 },
674 "terms": {"OnDemand": {
675 "c1": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
676 "pricePerUnit": {"USD": "3.0"}}}}},
677 "c2": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
678 "pricePerUnit": {"USD": "15.0"}}}}},
679 "c3": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
680 "pricePerUnit": {"USD": "0.3"}}}}},
681 "c4": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
682 "pricePerUnit": {"USD": "3.75"}}}}},
683 "c5": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
684 "pricePerUnit": {"USD": "6.0"}}}}},
685 "c6": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
686 "pricePerUnit": {"USD": "99.0"}}}}}
687 }}
688 });
689 let mut acc: HashMap<String, Partial> = HashMap::new();
690 parse_bedrock_offer(&offer1, &mut acc, false);
691 parse_bedrock_offer(&offer2, &mut acc, true);
692 acc.into_iter()
693 .filter_map(|(k, p)| p.finish().map(|mp| (k, mp)))
694 .collect()
695 }
696
697 #[test]
698 fn bedrock_model_key_normalizes_profiles_dates_and_revisions() {
699 assert_eq!(
700 model_key("us.anthropic.claude-3-5-sonnet-20241022-v2:0"),
701 "claude35sonnetv2"
702 );
703 assert_eq!(model_key("amazon.nova-lite-v1:0"), "novalitev1");
704 assert_eq!(model_key("Claude 3.5 Sonnet v2"), "claude35sonnetv2");
705 assert_eq!(
706 model_key("anthropic.claude-3-haiku-20240307-v1:0"),
707 "claude3haikuv1"
708 );
709 }
710
711 #[test]
712 fn bedrock_units_convert_to_per_million() {
713 assert!((to_per_million(0.003, "1K tokens").unwrap() - 3.0).abs() < 1e-9);
714 assert!((to_per_million(3.0, "1M tokens").unwrap() - 3.0).abs() < 1e-9);
715 assert!((to_per_million(0.000_003, "tokens").unwrap() - 3.0).abs() < 1e-9);
716 assert!(to_per_million(176.0, "hour").is_none());
717 }
718
719 #[test]
720 fn bedrock_offer1_skips_tier_variants() {
721 let catalog = bedrock_catalog();
722 let nova = catalog.get("novalite").unwrap();
723 assert!(
725 (nova.input_per_1m - 0.06).abs() < 1e-9,
726 "got {}",
727 nova.input_per_1m
728 );
729 assert!((nova.output_per_1m - 0.24).abs() < 1e-9);
730 }
731
732 #[test]
733 fn bedrock_offer2_includes_cache_and_skips_global_and_1h() {
734 let catalog = bedrock_catalog();
735 let claude = catalog.get("claude35sonnetv2").unwrap();
736 assert!((claude.input_per_1m - 3.0).abs() < 1e-9);
737 assert!((claude.output_per_1m - 15.0).abs() < 1e-9);
738 assert!((claude.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
739 assert!((claude.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
741 }
742
743 #[test]
744 fn bedrock_lookup_matches_full_ids_and_prefixes() {
745 let catalog = bedrock_catalog();
746 assert!(lookup_bedrock(&catalog, "us.anthropic.claude-3-5-sonnet-20241022-v2:0").is_some());
747 assert!(lookup_bedrock(&catalog, "anthropic.claude-3-5-sonnet-20240620-v1:0").is_some());
748 assert!(lookup_bedrock(&catalog, "amazon.nova-lite-v1:0").is_some());
749 assert!(lookup_bedrock(&catalog, "amazon.nova-pro-v1:0").is_none());
750 }
751
752 #[tokio::test]
755 #[ignore = "hits the live AWS Price List"]
756 async fn bedrock_live_prices() {
757 let client = reqwest::Client::builder()
758 .timeout(std::time::Duration::from_secs(60))
759 .build()
760 .unwrap();
761 let catalog = super::fetch_bedrock_catalog(&client, "us-east-1")
762 .await
763 .expect("fetch live catalog");
764 let claude = lookup_bedrock(&catalog, "us.anthropic.claude-3-5-sonnet-20241022-v2:0")
765 .expect("claude 3.5 sonnet v2");
766 assert!((claude.input_per_1m - 3.0).abs() < 1e-6, "{claude:?}");
767 assert!((claude.output_per_1m - 15.0).abs() < 1e-6, "{claude:?}");
768 assert!(
769 (claude.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-6,
770 "{claude:?}"
771 );
772 assert!(
773 (claude.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-6,
774 "{claude:?}"
775 );
776 let nova = lookup_bedrock(&catalog, "amazon.nova-lite-v1:0").expect("nova lite");
777 assert!(nova.input_per_1m > 0.0, "{nova:?}");
778 }
779
780 #[test]
781 fn pricing_source_defaults_to_auto() {
782 use super::{source_for, Source};
783 use crate::scenario::Provider;
784 let bedrock = EndpointConfig {
786 provider: Provider::Bedrock,
787 ..EndpointConfig::default()
788 };
789 assert_eq!(source_for(&bedrock), Some(Source::Bedrock));
790 let openrouter = EndpointConfig {
791 url: Some("https://openrouter.ai/api/v1".to_owned()),
792 ..EndpointConfig::default()
793 };
794 assert_eq!(source_for(&openrouter), Some(Source::OpenRouter));
795 let plain = EndpointConfig {
796 url: Some("https://api.openai.com/v1".to_owned()),
797 ..EndpointConfig::default()
798 };
799 assert_eq!(source_for(&plain), None);
800 let off = EndpointConfig {
802 provider: Provider::Bedrock,
803 pricing_source: Some("off".to_owned()),
804 ..EndpointConfig::default()
805 };
806 assert_eq!(source_for(&off), None);
807 }
808
809 fn catalog() -> serde_json::Value {
810 serde_json::json!({
811 "data": [
812 {"id": "anthropic/claude-3.5-sonnet", "pricing": {
813 "prompt": "0.000003", "completion": "0.000015",
814 "input_cache_read": "0.0000003", "input_cache_write": "0.00000375"
815 }},
816 {"id": "openrouter/auto", "pricing": {"prompt": "-1", "completion": "-1"}}
817 ]
818 })
819 }
820
821 #[test]
822 fn parses_exact_match_with_cache_prices() {
823 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
824 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
825 assert!((p.output_per_1m - 15.0).abs() < 1e-9);
826 assert!((p.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
827 assert!((p.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
828 }
829
830 #[test]
831 fn parses_by_leaf_when_namespaced_omitted() {
832 let p = parse_openrouter_models(&catalog(), "claude-3.5-sonnet").unwrap();
833 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
834 }
835
836 #[test]
837 fn dynamic_negative_prices_are_not_usable() {
838 assert!(parse_openrouter_models(&catalog(), "openrouter/auto").is_none());
839 }
840
841 #[test]
842 fn unknown_model_is_none() {
843 assert!(parse_openrouter_models(&catalog(), "does/not-exist").is_none());
844 }
845
846 #[test]
847 fn apply_pricing_keeps_explicit_values() {
848 let mut ec = EndpointConfig {
849 model: Some("anthropic/claude-3.5-sonnet".to_owned()),
850 pricing: Some(PricingConfig {
851 input_per_1m_tokens: 9.0,
852 output_per_1m_tokens: 0.0,
853 ..PricingConfig::default()
854 }),
855 ..EndpointConfig::default()
856 };
857 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
858 apply_pricing(&mut ec, &p);
859 let pricing = ec.pricing.unwrap();
860 assert!(
861 (pricing.input_per_1m_tokens - 9.0).abs() < 1e-9,
862 "explicit wins"
863 );
864 assert!(
865 (pricing.output_per_1m_tokens - 15.0).abs() < 1e-9,
866 "fetched fills gap"
867 );
868 assert!(pricing.cached_input_per_1m_tokens.is_some());
869 assert!(pricing.cache_write_per_1m_tokens.is_some());
870 }
871}