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 resp = client
83 .get("https://openrouter.ai/api/v1/models")
84 .send()
85 .await
86 .map_err(|e| format!("OpenRouter models request failed: {e}"))?;
87 if !resp.status().is_success() {
88 return Err(format!(
89 "OpenRouter models request returned HTTP {}",
90 resp.status()
91 ));
92 }
93 let json: serde_json::Value = resp
94 .json()
95 .await
96 .map_err(|e| format!("OpenRouter models response was not JSON: {e}"))?;
97 parse_openrouter_models(&json, model)
98 .ok_or_else(|| format!("model `{model}` not found in OpenRouter pricing"))
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103enum Source {
104 OpenRouter,
106 Bedrock,
108}
109
110#[must_use]
116fn source_for(ec: &EndpointConfig) -> Option<Source> {
117 match ec.pricing_source.as_deref() {
118 Some("openrouter") => Some(Source::OpenRouter),
119 Some("bedrock") => Some(Source::Bedrock),
120 Some("auto") => {
121 if ec.provider == Provider::Bedrock {
122 Some(Source::Bedrock)
123 } else if ec
124 .url
125 .as_deref()
126 .is_some_and(|url| url.to_ascii_lowercase().contains("openrouter.ai"))
127 {
128 Some(Source::OpenRouter)
129 } else {
130 None
131 }
132 }
133 _ => None,
134 }
135}
136
137pub fn apply_pricing(ec: &mut EndpointConfig, pricing: &ModelPricing) {
140 let p = ec.pricing.get_or_insert_with(Default::default);
141 if p.input_per_1m_tokens == 0.0 {
142 p.input_per_1m_tokens = pricing.input_per_1m;
143 }
144 if p.output_per_1m_tokens == 0.0 {
145 p.output_per_1m_tokens = pricing.output_per_1m;
146 }
147 if p.cached_input_per_1m_tokens.is_none() {
148 p.cached_input_per_1m_tokens = pricing.cached_input_per_1m;
149 }
150 if p.cache_write_per_1m_tokens.is_none() {
151 p.cache_write_per_1m_tokens = pricing.cache_write_per_1m;
152 }
153}
154
155#[allow(clippy::implicit_hasher, clippy::too_many_lines)]
167pub async fn apply_auto_pricing(
168 endpoints: &mut HashMap<String, EndpointConfig>,
169 client: &reqwest::Client,
170) -> Result<usize, String> {
171 let mut priced = 0;
172 let mut first_error: Option<String> = None;
173 let mut bedrock_catalogs: HashMap<String, HashMap<String, ModelPricing>> = HashMap::new();
175 for ec in endpoints.values_mut() {
176 let Some(model) = ec.model.clone() else {
177 continue;
178 };
179 let result = match source_for(ec) {
180 Some(Source::OpenRouter) => fetch_openrouter_pricing(client, &model).await,
181 Some(Source::Bedrock) => {
182 let region = bedrock_region(ec);
183 if !bedrock_catalogs.contains_key(®ion) {
184 match fetch_bedrock_catalog(client, ®ion).await {
185 Ok(catalog) => {
186 bedrock_catalogs.insert(region.clone(), catalog);
187 }
188 Err(e) => {
189 first_error.get_or_insert(e);
190 continue;
191 }
192 }
193 }
194 let catalog = &bedrock_catalogs[®ion];
195 lookup_bedrock(catalog, &model)
196 .ok_or_else(|| format!("model `{model}` not found in Bedrock pricing"))
197 }
198 None => continue,
199 };
200 match result {
201 Ok(pricing) => {
202 apply_pricing(ec, &pricing);
203 priced += 1;
204 }
205 Err(e) => {
206 first_error.get_or_insert(e);
207 }
208 }
209 }
210 match first_error {
211 Some(e) if priced == 0 => Err(e),
212 _ => Ok(priced),
213 }
214}
215
216#[must_use]
220fn bedrock_region(ec: &EndpointConfig) -> String {
221 ec.aws
222 .region
223 .clone()
224 .or_else(|| std::env::var("AWS_REGION").ok())
225 .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
226 .filter(|r| !r.trim().is_empty())
227 .unwrap_or_else(|| "us-east-1".to_owned())
228}
229
230const PRICING_HOST: &str = "https://pricing.us-east-1.amazonaws.com";
232
233const BEDROCK_OFFERS: [&str; 2] = ["AmazonBedrock", "AmazonBedrockFoundationModels"];
236
237async fn fetch_bedrock_catalog(
245 client: &reqwest::Client,
246 region: &str,
247) -> Result<HashMap<String, ModelPricing>, String> {
248 let mut acc: HashMap<String, Partial> = HashMap::new();
249 for offer in BEDROCK_OFFERS {
250 let doc = fetch_offer_region(client, offer, region).await?;
251 let overwrite = offer == "AmazonBedrockFoundationModels";
254 parse_bedrock_offer(&doc, &mut acc, overwrite);
255 }
256 Ok(acc
257 .into_iter()
258 .filter_map(|(key, partial)| partial.finish().map(|p| (key, p)))
259 .collect())
260}
261
262async fn fetch_offer_region(
264 client: &reqwest::Client,
265 offer: &str,
266 region: &str,
267) -> Result<serde_json::Value, String> {
268 let index_url = format!("{PRICING_HOST}/offers/v1.0/aws/{offer}/current/region_index.json");
269 let index: serde_json::Value = client
270 .get(&index_url)
271 .send()
272 .await
273 .map_err(|e| format!("{offer} region index request failed: {e}"))?
274 .json()
275 .await
276 .map_err(|e| format!("{offer} region index was not JSON: {e}"))?;
277 let path = index["regions"][region]["currentVersionUrl"]
278 .as_str()
279 .ok_or_else(|| format!("region `{region}` not present in {offer} price list"))?;
280 let url = format!("{PRICING_HOST}{path}");
281 client
282 .get(&url)
283 .send()
284 .await
285 .map_err(|e| format!("{offer} price list request failed: {e}"))?
286 .json()
287 .await
288 .map_err(|e| format!("{offer} price list was not JSON: {e}"))
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum Role {
294 Input,
296 Output,
298 Read,
300 Write,
302}
303
304#[derive(Debug, Default, Clone, Copy)]
306struct Partial {
307 input: Option<f64>,
308 output: Option<f64>,
309 read: Option<f64>,
310 write: Option<f64>,
311}
312
313impl Partial {
314 const fn set(&mut self, role: Role, value: f64, overwrite: bool) {
315 let slot = match role {
316 Role::Input => &mut self.input,
317 Role::Output => &mut self.output,
318 Role::Read => &mut self.read,
319 Role::Write => &mut self.write,
320 };
321 if overwrite || slot.is_none() {
322 *slot = Some(value);
323 }
324 }
325
326 fn finish(self) -> Option<ModelPricing> {
327 Some(ModelPricing {
328 input_per_1m: self.input?,
329 output_per_1m: self.output?,
330 cached_input_per_1m: self.read,
331 cache_write_per_1m: self.write,
332 })
333 }
334}
335
336fn parse_bedrock_offer(
338 doc: &serde_json::Value,
339 acc: &mut HashMap<String, Partial>,
340 overwrite: bool,
341) {
342 let Some(products) = doc["products"].as_object() else {
343 return;
344 };
345 for (sku, product) in products {
346 let a = &product["attributes"];
347 let Some((model, role)) = bedrock_entry(a) else {
348 continue;
349 };
350 let Some((usd, unit)) = on_demand_price(doc, sku) else {
351 continue;
352 };
353 let Some(per_million) = to_per_million(usd, &unit) else {
354 continue;
355 };
356 acc.entry(compact_key(model))
357 .or_default()
358 .set(role, per_million, overwrite);
359 }
360}
361
362fn bedrock_entry(attrs: &serde_json::Value) -> Option<(&str, Role)> {
366 if let Some(model) = attrs["model"].as_str().filter(|m| !m.is_empty()) {
369 if attrs["feature"].as_str() != Some("On-demand Inference") {
370 return None;
371 }
372 if attrs["batch"].as_str().is_some_and(|b| !b.is_empty()) {
373 return None;
374 }
375 let role = offer1_role(attrs["inferenceType"].as_str().unwrap_or_default())?;
376 return Some((model, role));
377 }
378 if let Some(servicename) = attrs["servicename"].as_str() {
381 if let Some(role) = offer2_role(attrs["usagetype"].as_str().unwrap_or_default()) {
382 let model = servicename
383 .strip_suffix(" (Amazon Bedrock Edition)")
384 .unwrap_or(servicename);
385 return Some((model, role));
386 }
387 }
388 None
389}
390
391fn normalize_token(value: &str) -> String {
393 value
394 .chars()
395 .filter(char::is_ascii_alphanumeric)
396 .collect::<String>()
397 .to_ascii_lowercase()
398}
399
400fn offer1_role(inference_type: &str) -> Option<Role> {
402 let s = normalize_token(inference_type);
403 if ["priority", "flex", "batch", "global", "latency"]
404 .iter()
405 .any(|t| s.contains(t))
406 {
407 return None;
408 }
409 if s.contains("cacheread") {
410 Some(Role::Read)
411 } else if s.contains("cachewrite") {
412 Some(Role::Write)
413 } else if s.contains("input")
414 && !s.contains("image")
415 && !s.contains("video")
416 && !s.contains("audio")
417 {
418 Some(Role::Input)
419 } else if s.contains("output") && !s.contains("image") && !s.contains("video") {
420 Some(Role::Output)
421 } else {
422 None
423 }
424}
425
426fn offer2_role(usagetype: &str) -> Option<Role> {
429 let s = normalize_token(usagetype);
430 if [
431 "global", "batch", "priority", "flex", "latency", "1h", "30m", "custom",
432 ]
433 .iter()
434 .any(|t| s.contains(t))
435 {
436 return None;
437 }
438 if s.contains("cacheread") {
439 Some(Role::Read)
440 } else if s.contains("cachewrite") {
441 Some(Role::Write)
442 } else if s.contains("inputtoken") {
443 Some(Role::Input)
444 } else if s.contains("outputtoken") {
445 Some(Role::Output)
446 } else {
447 None
448 }
449}
450
451fn on_demand_price(doc: &serde_json::Value, sku: &str) -> Option<(f64, String)> {
453 let offers = doc["terms"]["OnDemand"][sku].as_object()?;
454 let offer = offers.values().next()?;
455 let dimension = offer["priceDimensions"].as_object()?.values().next()?;
456 let usd = dimension["pricePerUnit"]["USD"]
457 .as_str()?
458 .parse::<f64>()
459 .ok()?;
460 let unit = dimension["unit"].as_str().unwrap_or_default().to_owned();
461 Some((usd, unit))
462}
463
464fn to_per_million(price: f64, unit: &str) -> Option<f64> {
466 let unit = unit.to_ascii_lowercase();
467 if unit.contains("1m") || unit.contains("million") {
468 Some(price)
469 } else if unit.contains("1k") || unit.contains("thousand") {
470 Some(price * 1_000.0)
471 } else if unit.contains("token") {
472 Some(price * 1_000_000.0)
473 } else {
474 None
475 }
476}
477
478const MODEL_PREFIXES: [&str; 12] = [
480 "us", "eu", "apac", "global", "us-gov", "ca", "sa", "me", "af", "il", "ap", "gov",
481];
482
483const MODEL_PROVIDERS: [&str; 21] = [
485 "anthropic",
486 "amazon",
487 "meta",
488 "mistral",
489 "cohere",
490 "ai21",
491 "stability",
492 "deepseek",
493 "openai",
494 "google",
495 "qwen",
496 "writer",
497 "nvidia",
498 "minimax",
499 "moonshot",
500 "moonshotai",
501 "zai",
502 "xai",
503 "kimi",
504 "twelvelabs",
505 "luma",
506];
507
508#[must_use]
515fn model_key(model: &str) -> String {
516 let lower = model.to_ascii_lowercase();
517 let no_revision = lower.split(':').next().unwrap_or(&lower);
518 let kept: Vec<&str> = no_revision
519 .split('.')
520 .filter(|s| !MODEL_PREFIXES.contains(s) && !MODEL_PROVIDERS.contains(s))
521 .collect();
522 let joined = if kept.is_empty() {
523 no_revision
524 } else {
525 &kept.join("-")
526 };
527 compact_key(joined)
528}
529
530#[must_use]
532fn compact_key(value: &str) -> String {
533 let mut out = String::new();
534 for token in value.split(['-', '_', ' ']).filter(|t| !t.is_empty()) {
535 if token.len() == 8 && token.chars().all(|c| c.is_ascii_digit()) {
536 continue;
537 }
538 out.push_str(token);
539 }
540 out.chars()
541 .filter(char::is_ascii_alphanumeric)
542 .collect::<String>()
543 .to_ascii_lowercase()
544}
545
546#[must_use]
549fn lookup_bedrock(catalog: &HashMap<String, ModelPricing>, model: &str) -> Option<ModelPricing> {
550 let key = model_key(model);
551 if let Some(p) = catalog.get(&key) {
552 return Some(*p);
553 }
554 if let Some(p) = prefix_lookup(catalog, &key) {
555 return Some(p);
556 }
557 if let Some(stripped) = strip_trailing_version(&key) {
558 if let Some(p) = catalog.get(&stripped) {
559 return Some(*p);
560 }
561 if let Some(p) = prefix_lookup(catalog, &stripped) {
562 return Some(p);
563 }
564 }
565 None
566}
567
568fn prefix_lookup(catalog: &HashMap<String, ModelPricing>, key: &str) -> Option<ModelPricing> {
570 let mut keys: Vec<&String> = catalog.keys().collect();
571 keys.sort();
572 let mut best: Option<(&String, usize)> = None;
573 for candidate in keys {
574 if key.starts_with(candidate.as_str()) || candidate.starts_with(key) {
575 let diff = candidate.len().abs_diff(key.len());
576 if best.as_ref().is_none_or(|(_, d)| diff < *d) {
577 best = Some((candidate, diff));
578 }
579 }
580 }
581 best.and_then(|(k, _)| catalog.get(k).copied())
582}
583
584fn strip_trailing_version(key: &str) -> Option<String> {
586 let idx = key.rfind('v')?;
587 let rest = &key[idx + 1..];
588 if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
589 Some(key[..idx].to_owned())
590 } else {
591 None
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::{
598 apply_pricing, lookup_bedrock, model_key, parse_bedrock_offer, parse_openrouter_models,
599 to_per_million, Partial,
600 };
601 use crate::scenario::{EndpointConfig, PricingConfig};
602 use std::collections::HashMap;
603
604 fn bedrock_catalog() -> HashMap<String, super::ModelPricing> {
606 let offer1 = serde_json::json!({
607 "products": {
608 "s1": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
609 "inferenceType": "Input tokens", "batch": ""}},
610 "s2": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
611 "inferenceType": "Output tokens", "batch": ""}},
612 "s3": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
613 "inferenceType": "Input tokens flex", "batch": ""}},
614 "s4": {"attributes": {"model": "Nova Lite", "feature": "Batch Inference",
615 "inferenceType": "Input tokens", "batch": "true"}}
616 },
617 "terms": {"OnDemand": {
618 "s1": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
619 "pricePerUnit": {"USD": "0.00006"}}}}},
620 "s2": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
621 "pricePerUnit": {"USD": "0.00024"}}}}},
622 "s3": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
623 "pricePerUnit": {"USD": "0.00005"}}}}},
624 "s4": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
625 "pricePerUnit": {"USD": "0.00003"}}}}}
626 }}
627 });
628 let offer2 = serde_json::json!({
629 "products": {
630 "c1": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
631 "usagetype": "USE1-MP:USE1_InputTokenCount-Units"}},
632 "c2": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
633 "usagetype": "USE1-MP:USE1_OutputTokenCount-Units"}},
634 "c3": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
635 "usagetype": "USE1-MP:USE1_CacheReadInputTokenCount-Units"}},
636 "c4": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
637 "usagetype": "USE1-MP:USE1_CacheWriteInputTokenCount-Units"}},
638 "c5": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
639 "usagetype": "USE1-MP:USE1_CacheWrite1hInputTokenCount-Units"}},
640 "c6": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
641 "usagetype": "USE1-MP:USE1_InputTokenCount_Global-Units"}}
642 },
643 "terms": {"OnDemand": {
644 "c1": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
645 "pricePerUnit": {"USD": "3.0"}}}}},
646 "c2": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
647 "pricePerUnit": {"USD": "15.0"}}}}},
648 "c3": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
649 "pricePerUnit": {"USD": "0.3"}}}}},
650 "c4": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
651 "pricePerUnit": {"USD": "3.75"}}}}},
652 "c5": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
653 "pricePerUnit": {"USD": "6.0"}}}}},
654 "c6": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
655 "pricePerUnit": {"USD": "99.0"}}}}}
656 }}
657 });
658 let mut acc: HashMap<String, Partial> = HashMap::new();
659 parse_bedrock_offer(&offer1, &mut acc, false);
660 parse_bedrock_offer(&offer2, &mut acc, true);
661 acc.into_iter()
662 .filter_map(|(k, p)| p.finish().map(|mp| (k, mp)))
663 .collect()
664 }
665
666 #[test]
667 fn bedrock_model_key_normalizes_profiles_dates_and_revisions() {
668 assert_eq!(
669 model_key("us.anthropic.claude-3-5-sonnet-20241022-v2:0"),
670 "claude35sonnetv2"
671 );
672 assert_eq!(model_key("amazon.nova-lite-v1:0"), "novalitev1");
673 assert_eq!(model_key("Claude 3.5 Sonnet v2"), "claude35sonnetv2");
674 assert_eq!(
675 model_key("anthropic.claude-3-haiku-20240307-v1:0"),
676 "claude3haikuv1"
677 );
678 }
679
680 #[test]
681 fn bedrock_units_convert_to_per_million() {
682 assert!((to_per_million(0.003, "1K tokens").unwrap() - 3.0).abs() < 1e-9);
683 assert!((to_per_million(3.0, "1M tokens").unwrap() - 3.0).abs() < 1e-9);
684 assert!((to_per_million(0.000_003, "tokens").unwrap() - 3.0).abs() < 1e-9);
685 assert!(to_per_million(176.0, "hour").is_none());
686 }
687
688 #[test]
689 fn bedrock_offer1_skips_tier_variants() {
690 let catalog = bedrock_catalog();
691 let nova = catalog.get("novalite").unwrap();
692 assert!(
694 (nova.input_per_1m - 0.06).abs() < 1e-9,
695 "got {}",
696 nova.input_per_1m
697 );
698 assert!((nova.output_per_1m - 0.24).abs() < 1e-9);
699 }
700
701 #[test]
702 fn bedrock_offer2_includes_cache_and_skips_global_and_1h() {
703 let catalog = bedrock_catalog();
704 let claude = catalog.get("claude35sonnetv2").unwrap();
705 assert!((claude.input_per_1m - 3.0).abs() < 1e-9);
706 assert!((claude.output_per_1m - 15.0).abs() < 1e-9);
707 assert!((claude.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
708 assert!((claude.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
710 }
711
712 #[test]
713 fn bedrock_lookup_matches_full_ids_and_prefixes() {
714 let catalog = bedrock_catalog();
715 assert!(lookup_bedrock(&catalog, "us.anthropic.claude-3-5-sonnet-20241022-v2:0").is_some());
716 assert!(lookup_bedrock(&catalog, "anthropic.claude-3-5-sonnet-20240620-v1:0").is_some());
717 assert!(lookup_bedrock(&catalog, "amazon.nova-lite-v1:0").is_some());
718 assert!(lookup_bedrock(&catalog, "amazon.nova-pro-v1:0").is_none());
719 }
720
721 #[tokio::test]
724 #[ignore = "hits the live AWS Price List"]
725 async fn bedrock_live_prices() {
726 let client = reqwest::Client::builder()
727 .timeout(std::time::Duration::from_secs(60))
728 .build()
729 .unwrap();
730 let catalog = super::fetch_bedrock_catalog(&client, "us-east-1")
731 .await
732 .expect("fetch live catalog");
733 let claude = lookup_bedrock(&catalog, "us.anthropic.claude-3-5-sonnet-20241022-v2:0")
734 .expect("claude 3.5 sonnet v2");
735 assert!((claude.input_per_1m - 3.0).abs() < 1e-6, "{claude:?}");
736 assert!((claude.output_per_1m - 15.0).abs() < 1e-6, "{claude:?}");
737 assert!(
738 (claude.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-6,
739 "{claude:?}"
740 );
741 assert!(
742 (claude.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-6,
743 "{claude:?}"
744 );
745 let nova = lookup_bedrock(&catalog, "amazon.nova-lite-v1:0").expect("nova lite");
746 assert!(nova.input_per_1m > 0.0, "{nova:?}");
747 }
748
749 fn catalog() -> serde_json::Value {
750 serde_json::json!({
751 "data": [
752 {"id": "anthropic/claude-3.5-sonnet", "pricing": {
753 "prompt": "0.000003", "completion": "0.000015",
754 "input_cache_read": "0.0000003", "input_cache_write": "0.00000375"
755 }},
756 {"id": "openrouter/auto", "pricing": {"prompt": "-1", "completion": "-1"}}
757 ]
758 })
759 }
760
761 #[test]
762 fn parses_exact_match_with_cache_prices() {
763 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
764 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
765 assert!((p.output_per_1m - 15.0).abs() < 1e-9);
766 assert!((p.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
767 assert!((p.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
768 }
769
770 #[test]
771 fn parses_by_leaf_when_namespaced_omitted() {
772 let p = parse_openrouter_models(&catalog(), "claude-3.5-sonnet").unwrap();
773 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
774 }
775
776 #[test]
777 fn dynamic_negative_prices_are_not_usable() {
778 assert!(parse_openrouter_models(&catalog(), "openrouter/auto").is_none());
779 }
780
781 #[test]
782 fn unknown_model_is_none() {
783 assert!(parse_openrouter_models(&catalog(), "does/not-exist").is_none());
784 }
785
786 #[test]
787 fn apply_pricing_keeps_explicit_values() {
788 let mut ec = EndpointConfig {
789 model: Some("anthropic/claude-3.5-sonnet".to_owned()),
790 pricing: Some(PricingConfig {
791 input_per_1m_tokens: 9.0,
792 output_per_1m_tokens: 0.0,
793 ..PricingConfig::default()
794 }),
795 ..EndpointConfig::default()
796 };
797 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
798 apply_pricing(&mut ec, &p);
799 let pricing = ec.pricing.unwrap();
800 assert!(
801 (pricing.input_per_1m_tokens - 9.0).abs() < 1e-9,
802 "explicit wins"
803 );
804 assert!(
805 (pricing.output_per_1m_tokens - 15.0).abs() < 1e-9,
806 "fetched fills gap"
807 );
808 assert!(pricing.cached_input_per_1m_tokens.is_some());
809 assert!(pricing.cache_write_per_1m_tokens.is_some());
810 }
811}