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#[derive(Debug, Default)]
174pub struct PricingOutcome {
175 pub priced: usize,
177 pub unsupported: Vec<String>,
180 pub error: Option<String>,
182}
183
184fn needs_pricing(ec: &EndpointConfig) -> bool {
186 ec.pricing
187 .as_ref()
188 .is_none_or(|p| p.input_per_1m_tokens == 0.0 || p.output_per_1m_tokens == 0.0)
189}
190
191fn is_auto(ec: &EndpointConfig) -> bool {
193 ec.pricing_source
194 .as_deref()
195 .is_none_or(|s| s.eq_ignore_ascii_case("auto"))
196}
197
198#[allow(clippy::implicit_hasher, clippy::too_many_lines)]
208pub async fn apply_auto_pricing(
209 endpoints: &mut HashMap<String, EndpointConfig>,
210 client: &reqwest::Client,
211) -> PricingOutcome {
212 let mut outcome = PricingOutcome::default();
213 let mut bedrock_catalogs: HashMap<String, HashMap<String, ModelPricing>> = HashMap::new();
215 let mut openrouter_catalog: Option<serde_json::Value> = None;
216 for (name, ec) in endpoints.iter_mut() {
217 let Some(model) = ec.model.clone() else {
218 continue;
219 };
220 let result = match source_for(ec) {
221 Some(Source::OpenRouter) => {
222 if openrouter_catalog.is_none() {
223 match fetch_openrouter_models(client).await {
224 Ok(catalog) => openrouter_catalog = Some(catalog),
225 Err(e) => {
226 outcome.error.get_or_insert(e);
227 continue;
228 }
229 }
230 }
231 let catalog = openrouter_catalog.as_ref().expect("catalog fetched above");
232 parse_openrouter_models(catalog, &model)
233 .ok_or_else(|| format!("model `{model}` not found in OpenRouter pricing"))
234 }
235 Some(Source::Bedrock) => {
236 let region = bedrock_region(ec);
237 if !bedrock_catalogs.contains_key(®ion) {
238 match fetch_bedrock_catalog(client, ®ion).await {
239 Ok(catalog) => {
240 bedrock_catalogs.insert(region.clone(), catalog);
241 }
242 Err(e) => {
243 outcome.error.get_or_insert(e);
244 continue;
245 }
246 }
247 }
248 let catalog = &bedrock_catalogs[®ion];
249 lookup_bedrock(catalog, &model)
250 .ok_or_else(|| format!("model `{model}` not found in Bedrock pricing"))
251 }
252 None => {
253 if is_auto(ec) && needs_pricing(ec) {
256 outcome.unsupported.push(name.clone());
257 }
258 ec.pricing_source = Some("off".to_owned());
259 continue;
260 }
261 };
262 match result {
263 Ok(pricing) => {
264 apply_pricing(ec, &pricing);
265 outcome.priced += 1;
266 }
267 Err(e) => {
268 outcome.error.get_or_insert(e);
269 }
270 }
271 }
272 outcome
273}
274
275#[must_use]
279fn bedrock_region(ec: &EndpointConfig) -> String {
280 ec.aws
281 .region
282 .clone()
283 .or_else(|| std::env::var("AWS_REGION").ok())
284 .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
285 .filter(|r| !r.trim().is_empty())
286 .unwrap_or_else(|| "us-east-1".to_owned())
287}
288
289const PRICING_HOST: &str = "https://pricing.us-east-1.amazonaws.com";
291
292const BEDROCK_OFFERS: [&str; 2] = ["AmazonBedrock", "AmazonBedrockFoundationModels"];
295
296async fn fetch_bedrock_catalog(
304 client: &reqwest::Client,
305 region: &str,
306) -> Result<HashMap<String, ModelPricing>, String> {
307 let mut acc: HashMap<String, Partial> = HashMap::new();
308 for offer in BEDROCK_OFFERS {
309 let doc = fetch_offer_region(client, offer, region).await?;
310 let overwrite = offer == "AmazonBedrockFoundationModels";
313 parse_bedrock_offer(&doc, &mut acc, overwrite);
314 }
315 Ok(acc
316 .into_iter()
317 .filter_map(|(key, partial)| partial.finish().map(|p| (key, p)))
318 .collect())
319}
320
321async fn fetch_offer_region(
323 client: &reqwest::Client,
324 offer: &str,
325 region: &str,
326) -> Result<serde_json::Value, String> {
327 let index_url = format!("{PRICING_HOST}/offers/v1.0/aws/{offer}/current/region_index.json");
328 let index: serde_json::Value = client
329 .get(&index_url)
330 .send()
331 .await
332 .map_err(|e| format!("{offer} region index request failed: {e}"))?
333 .json()
334 .await
335 .map_err(|e| format!("{offer} region index was not JSON: {e}"))?;
336 let path = index["regions"][region]["currentVersionUrl"]
337 .as_str()
338 .ok_or_else(|| format!("region `{region}` not present in {offer} price list"))?;
339 let url = format!("{PRICING_HOST}{path}");
340 client
341 .get(&url)
342 .send()
343 .await
344 .map_err(|e| format!("{offer} price list request failed: {e}"))?
345 .json()
346 .await
347 .map_err(|e| format!("{offer} price list was not JSON: {e}"))
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352enum Role {
353 Input,
355 Output,
357 Read,
359 Write,
361}
362
363#[derive(Debug, Default, Clone, Copy)]
365struct Partial {
366 input: Option<f64>,
367 output: Option<f64>,
368 read: Option<f64>,
369 write: Option<f64>,
370}
371
372impl Partial {
373 const fn set(&mut self, role: Role, value: f64, overwrite: bool) {
374 let slot = match role {
375 Role::Input => &mut self.input,
376 Role::Output => &mut self.output,
377 Role::Read => &mut self.read,
378 Role::Write => &mut self.write,
379 };
380 if overwrite || slot.is_none() {
381 *slot = Some(value);
382 }
383 }
384
385 fn finish(self) -> Option<ModelPricing> {
386 Some(ModelPricing {
387 input_per_1m: self.input?,
388 output_per_1m: self.output?,
389 cached_input_per_1m: self.read,
390 cache_write_per_1m: self.write,
391 })
392 }
393}
394
395fn parse_bedrock_offer(
397 doc: &serde_json::Value,
398 acc: &mut HashMap<String, Partial>,
399 overwrite: bool,
400) {
401 let Some(products) = doc["products"].as_object() else {
402 return;
403 };
404 for (sku, product) in products {
405 let a = &product["attributes"];
406 let Some((model, role)) = bedrock_entry(a) else {
407 continue;
408 };
409 let Some((usd, unit)) = on_demand_price(doc, sku) else {
410 continue;
411 };
412 let Some(per_million) = to_per_million(usd, &unit) else {
413 continue;
414 };
415 acc.entry(compact_key(model))
416 .or_default()
417 .set(role, per_million, overwrite);
418 }
419}
420
421fn bedrock_entry(attrs: &serde_json::Value) -> Option<(&str, Role)> {
425 if let Some(model) = attrs["model"].as_str().filter(|m| !m.is_empty()) {
428 if attrs["feature"].as_str() != Some("On-demand Inference") {
429 return None;
430 }
431 if attrs["batch"].as_str().is_some_and(|b| !b.is_empty()) {
432 return None;
433 }
434 let role = offer1_role(attrs["inferenceType"].as_str().unwrap_or_default())?;
435 return Some((model, role));
436 }
437 if let Some(servicename) = attrs["servicename"].as_str() {
440 if let Some(role) = offer2_role(attrs["usagetype"].as_str().unwrap_or_default()) {
441 let model = servicename
442 .strip_suffix(" (Amazon Bedrock Edition)")
443 .unwrap_or(servicename);
444 return Some((model, role));
445 }
446 }
447 None
448}
449
450fn normalize_token(value: &str) -> String {
452 value
453 .chars()
454 .filter(char::is_ascii_alphanumeric)
455 .collect::<String>()
456 .to_ascii_lowercase()
457}
458
459fn offer1_role(inference_type: &str) -> Option<Role> {
461 let s = normalize_token(inference_type);
462 if ["priority", "flex", "batch", "global", "latency"]
463 .iter()
464 .any(|t| s.contains(t))
465 {
466 return None;
467 }
468 if s.contains("cacheread") {
469 Some(Role::Read)
470 } else if s.contains("cachewrite") {
471 Some(Role::Write)
472 } else if s.contains("input")
473 && !s.contains("image")
474 && !s.contains("video")
475 && !s.contains("audio")
476 {
477 Some(Role::Input)
478 } else if s.contains("output") && !s.contains("image") && !s.contains("video") {
479 Some(Role::Output)
480 } else {
481 None
482 }
483}
484
485fn offer2_role(usagetype: &str) -> Option<Role> {
488 let s = normalize_token(usagetype);
489 if [
490 "global", "batch", "priority", "flex", "latency", "1h", "30m", "custom",
491 ]
492 .iter()
493 .any(|t| s.contains(t))
494 {
495 return None;
496 }
497 if s.contains("cacheread") {
498 Some(Role::Read)
499 } else if s.contains("cachewrite") {
500 Some(Role::Write)
501 } else if s.contains("inputtoken") {
502 Some(Role::Input)
503 } else if s.contains("outputtoken") {
504 Some(Role::Output)
505 } else {
506 None
507 }
508}
509
510fn on_demand_price(doc: &serde_json::Value, sku: &str) -> Option<(f64, String)> {
512 let offers = doc["terms"]["OnDemand"][sku].as_object()?;
513 let offer = offers.values().next()?;
514 let dimension = offer["priceDimensions"].as_object()?.values().next()?;
515 let usd = dimension["pricePerUnit"]["USD"]
516 .as_str()?
517 .parse::<f64>()
518 .ok()?;
519 let unit = dimension["unit"].as_str().unwrap_or_default().to_owned();
520 Some((usd, unit))
521}
522
523fn to_per_million(price: f64, unit: &str) -> Option<f64> {
525 let unit = unit.to_ascii_lowercase();
526 if unit.contains("1m") || unit.contains("million") {
527 Some(price)
528 } else if unit.contains("1k") || unit.contains("thousand") {
529 Some(price * 1_000.0)
530 } else if unit.contains("token") {
531 Some(price * 1_000_000.0)
532 } else {
533 None
534 }
535}
536
537const MODEL_PREFIXES: [&str; 12] = [
539 "us", "eu", "apac", "global", "us-gov", "ca", "sa", "me", "af", "il", "ap", "gov",
540];
541
542const MODEL_PROVIDERS: [&str; 21] = [
544 "anthropic",
545 "amazon",
546 "meta",
547 "mistral",
548 "cohere",
549 "ai21",
550 "stability",
551 "deepseek",
552 "openai",
553 "google",
554 "qwen",
555 "writer",
556 "nvidia",
557 "minimax",
558 "moonshot",
559 "moonshotai",
560 "zai",
561 "xai",
562 "kimi",
563 "twelvelabs",
564 "luma",
565];
566
567#[must_use]
574fn model_key(model: &str) -> String {
575 let lower = model.to_ascii_lowercase();
576 let no_revision = lower.split(':').next().unwrap_or(&lower);
577 let kept: Vec<&str> = no_revision
578 .split('.')
579 .filter(|s| !MODEL_PREFIXES.contains(s) && !MODEL_PROVIDERS.contains(s))
580 .collect();
581 let joined = if kept.is_empty() {
582 no_revision
583 } else {
584 &kept.join("-")
585 };
586 compact_key(joined)
587}
588
589#[must_use]
591fn compact_key(value: &str) -> String {
592 let mut out = String::new();
593 for token in value.split(['-', '_', ' ']).filter(|t| !t.is_empty()) {
594 if token.len() == 8 && token.chars().all(|c| c.is_ascii_digit()) {
595 continue;
596 }
597 out.push_str(token);
598 }
599 out.chars()
600 .filter(char::is_ascii_alphanumeric)
601 .collect::<String>()
602 .to_ascii_lowercase()
603}
604
605#[must_use]
608fn lookup_bedrock(catalog: &HashMap<String, ModelPricing>, model: &str) -> Option<ModelPricing> {
609 let key = model_key(model);
610 if let Some(p) = catalog.get(&key) {
611 return Some(*p);
612 }
613 if let Some(p) = prefix_lookup(catalog, &key) {
614 return Some(p);
615 }
616 if let Some(stripped) = strip_trailing_version(&key) {
617 if let Some(p) = catalog.get(&stripped) {
618 return Some(*p);
619 }
620 if let Some(p) = prefix_lookup(catalog, &stripped) {
621 return Some(p);
622 }
623 }
624 None
625}
626
627fn prefix_lookup(catalog: &HashMap<String, ModelPricing>, key: &str) -> Option<ModelPricing> {
629 let mut keys: Vec<&String> = catalog.keys().collect();
630 keys.sort();
631 let mut best: Option<(&String, usize)> = None;
632 for candidate in keys {
633 if key.starts_with(candidate.as_str()) || candidate.starts_with(key) {
634 let diff = candidate.len().abs_diff(key.len());
635 if best.as_ref().is_none_or(|(_, d)| diff < *d) {
636 best = Some((candidate, diff));
637 }
638 }
639 }
640 best.and_then(|(k, _)| catalog.get(k).copied())
641}
642
643fn strip_trailing_version(key: &str) -> Option<String> {
645 let idx = key.rfind('v')?;
646 let rest = &key[idx + 1..];
647 if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
648 Some(key[..idx].to_owned())
649 } else {
650 None
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::{
657 apply_pricing, lookup_bedrock, model_key, parse_bedrock_offer, parse_openrouter_models,
658 to_per_million, Partial,
659 };
660 use crate::scenario::{EndpointConfig, PricingConfig};
661 use std::collections::HashMap;
662
663 fn bedrock_catalog() -> HashMap<String, super::ModelPricing> {
665 let offer1 = serde_json::json!({
666 "products": {
667 "s1": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
668 "inferenceType": "Input tokens", "batch": ""}},
669 "s2": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
670 "inferenceType": "Output tokens", "batch": ""}},
671 "s3": {"attributes": {"model": "Nova Lite", "feature": "On-demand Inference",
672 "inferenceType": "Input tokens flex", "batch": ""}},
673 "s4": {"attributes": {"model": "Nova Lite", "feature": "Batch Inference",
674 "inferenceType": "Input tokens", "batch": "true"}}
675 },
676 "terms": {"OnDemand": {
677 "s1": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
678 "pricePerUnit": {"USD": "0.00006"}}}}},
679 "s2": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
680 "pricePerUnit": {"USD": "0.00024"}}}}},
681 "s3": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
682 "pricePerUnit": {"USD": "0.00005"}}}}},
683 "s4": {"o": {"priceDimensions": {"d": {"unit": "1K tokens",
684 "pricePerUnit": {"USD": "0.00003"}}}}}
685 }}
686 });
687 let offer2 = serde_json::json!({
688 "products": {
689 "c1": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
690 "usagetype": "USE1-MP:USE1_InputTokenCount-Units"}},
691 "c2": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
692 "usagetype": "USE1-MP:USE1_OutputTokenCount-Units"}},
693 "c3": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
694 "usagetype": "USE1-MP:USE1_CacheReadInputTokenCount-Units"}},
695 "c4": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
696 "usagetype": "USE1-MP:USE1_CacheWriteInputTokenCount-Units"}},
697 "c5": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
698 "usagetype": "USE1-MP:USE1_CacheWrite1hInputTokenCount-Units"}},
699 "c6": {"attributes": {"servicename": "Claude 3.5 Sonnet v2 (Amazon Bedrock Edition)",
700 "usagetype": "USE1-MP:USE1_InputTokenCount_Global-Units"}}
701 },
702 "terms": {"OnDemand": {
703 "c1": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
704 "pricePerUnit": {"USD": "3.0"}}}}},
705 "c2": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
706 "pricePerUnit": {"USD": "15.0"}}}}},
707 "c3": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
708 "pricePerUnit": {"USD": "0.3"}}}}},
709 "c4": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
710 "pricePerUnit": {"USD": "3.75"}}}}},
711 "c5": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
712 "pricePerUnit": {"USD": "6.0"}}}}},
713 "c6": {"o": {"priceDimensions": {"d": {"unit": "1M tokens",
714 "pricePerUnit": {"USD": "99.0"}}}}}
715 }}
716 });
717 let mut acc: HashMap<String, Partial> = HashMap::new();
718 parse_bedrock_offer(&offer1, &mut acc, false);
719 parse_bedrock_offer(&offer2, &mut acc, true);
720 acc.into_iter()
721 .filter_map(|(k, p)| p.finish().map(|mp| (k, mp)))
722 .collect()
723 }
724
725 #[test]
726 fn bedrock_model_key_normalizes_profiles_dates_and_revisions() {
727 assert_eq!(
728 model_key("us.anthropic.claude-3-5-sonnet-20241022-v2:0"),
729 "claude35sonnetv2"
730 );
731 assert_eq!(model_key("amazon.nova-lite-v1:0"), "novalitev1");
732 assert_eq!(model_key("Claude 3.5 Sonnet v2"), "claude35sonnetv2");
733 assert_eq!(
734 model_key("anthropic.claude-3-haiku-20240307-v1:0"),
735 "claude3haikuv1"
736 );
737 }
738
739 #[test]
740 fn bedrock_units_convert_to_per_million() {
741 assert!((to_per_million(0.003, "1K tokens").unwrap() - 3.0).abs() < 1e-9);
742 assert!((to_per_million(3.0, "1M tokens").unwrap() - 3.0).abs() < 1e-9);
743 assert!((to_per_million(0.000_003, "tokens").unwrap() - 3.0).abs() < 1e-9);
744 assert!(to_per_million(176.0, "hour").is_none());
745 }
746
747 #[test]
748 fn bedrock_offer1_skips_tier_variants() {
749 let catalog = bedrock_catalog();
750 let nova = catalog.get("novalite").unwrap();
751 assert!(
753 (nova.input_per_1m - 0.06).abs() < 1e-9,
754 "got {}",
755 nova.input_per_1m
756 );
757 assert!((nova.output_per_1m - 0.24).abs() < 1e-9);
758 }
759
760 #[test]
761 fn bedrock_offer2_includes_cache_and_skips_global_and_1h() {
762 let catalog = bedrock_catalog();
763 let claude = catalog.get("claude35sonnetv2").unwrap();
764 assert!((claude.input_per_1m - 3.0).abs() < 1e-9);
765 assert!((claude.output_per_1m - 15.0).abs() < 1e-9);
766 assert!((claude.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
767 assert!((claude.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
769 }
770
771 #[test]
772 fn bedrock_lookup_matches_full_ids_and_prefixes() {
773 let catalog = bedrock_catalog();
774 assert!(lookup_bedrock(&catalog, "us.anthropic.claude-3-5-sonnet-20241022-v2:0").is_some());
775 assert!(lookup_bedrock(&catalog, "anthropic.claude-3-5-sonnet-20240620-v1:0").is_some());
776 assert!(lookup_bedrock(&catalog, "amazon.nova-lite-v1:0").is_some());
777 assert!(lookup_bedrock(&catalog, "amazon.nova-pro-v1:0").is_none());
778 }
779
780 #[tokio::test]
783 #[ignore = "hits the live AWS Price List"]
784 async fn bedrock_live_prices() {
785 let client = reqwest::Client::builder()
786 .timeout(std::time::Duration::from_secs(60))
787 .build()
788 .unwrap();
789 let catalog = super::fetch_bedrock_catalog(&client, "us-east-1")
790 .await
791 .expect("fetch live catalog");
792 let claude = lookup_bedrock(&catalog, "us.anthropic.claude-3-5-sonnet-20241022-v2:0")
793 .expect("claude 3.5 sonnet v2");
794 assert!((claude.input_per_1m - 3.0).abs() < 1e-6, "{claude:?}");
795 assert!((claude.output_per_1m - 15.0).abs() < 1e-6, "{claude:?}");
796 assert!(
797 (claude.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-6,
798 "{claude:?}"
799 );
800 assert!(
801 (claude.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-6,
802 "{claude:?}"
803 );
804 let nova = lookup_bedrock(&catalog, "amazon.nova-lite-v1:0").expect("nova lite");
805 assert!(nova.input_per_1m > 0.0, "{nova:?}");
806 }
807
808 #[test]
809 fn pricing_source_defaults_to_auto() {
810 use super::{source_for, Source};
811 use crate::scenario::Provider;
812 let bedrock = EndpointConfig {
814 provider: Provider::Bedrock,
815 ..EndpointConfig::default()
816 };
817 assert_eq!(source_for(&bedrock), Some(Source::Bedrock));
818 let openrouter = EndpointConfig {
819 url: Some("https://openrouter.ai/api/v1".to_owned()),
820 ..EndpointConfig::default()
821 };
822 assert_eq!(source_for(&openrouter), Some(Source::OpenRouter));
823 let plain = EndpointConfig {
824 url: Some("https://api.openai.com/v1".to_owned()),
825 ..EndpointConfig::default()
826 };
827 assert_eq!(source_for(&plain), None);
828 let off = EndpointConfig {
830 provider: Provider::Bedrock,
831 pricing_source: Some("off".to_owned()),
832 ..EndpointConfig::default()
833 };
834 assert_eq!(source_for(&off), None);
835 }
836
837 #[tokio::test]
838 async fn auto_marks_unsupported_provider_off() {
839 let client = reqwest::Client::new();
840 let mut endpoints = HashMap::new();
841 endpoints.insert(
842 "oai".to_owned(),
843 EndpointConfig {
844 url: Some("https://api.openai.com/v1".to_owned()),
845 model: Some("gpt-4o".to_owned()),
846 ..EndpointConfig::default()
847 },
848 );
849 let outcome = super::apply_auto_pricing(&mut endpoints, &client).await;
850 assert_eq!(outcome.priced, 0);
851 assert_eq!(outcome.unsupported, vec!["oai".to_owned()]);
852 assert_eq!(endpoints["oai"].pricing_source.as_deref(), Some("off"));
853 }
854
855 fn catalog() -> serde_json::Value {
856 serde_json::json!({
857 "data": [
858 {"id": "anthropic/claude-3.5-sonnet", "pricing": {
859 "prompt": "0.000003", "completion": "0.000015",
860 "input_cache_read": "0.0000003", "input_cache_write": "0.00000375"
861 }},
862 {"id": "openrouter/auto", "pricing": {"prompt": "-1", "completion": "-1"}}
863 ]
864 })
865 }
866
867 #[test]
868 fn parses_exact_match_with_cache_prices() {
869 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
870 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
871 assert!((p.output_per_1m - 15.0).abs() < 1e-9);
872 assert!((p.cached_input_per_1m.unwrap() - 0.3).abs() < 1e-9);
873 assert!((p.cache_write_per_1m.unwrap() - 3.75).abs() < 1e-9);
874 }
875
876 #[test]
877 fn parses_by_leaf_when_namespaced_omitted() {
878 let p = parse_openrouter_models(&catalog(), "claude-3.5-sonnet").unwrap();
879 assert!((p.input_per_1m - 3.0).abs() < 1e-9);
880 }
881
882 #[test]
883 fn dynamic_negative_prices_are_not_usable() {
884 assert!(parse_openrouter_models(&catalog(), "openrouter/auto").is_none());
885 }
886
887 #[test]
888 fn unknown_model_is_none() {
889 assert!(parse_openrouter_models(&catalog(), "does/not-exist").is_none());
890 }
891
892 #[test]
893 fn apply_pricing_keeps_explicit_values() {
894 let mut ec = EndpointConfig {
895 model: Some("anthropic/claude-3.5-sonnet".to_owned()),
896 pricing: Some(PricingConfig {
897 input_per_1m_tokens: 9.0,
898 output_per_1m_tokens: 0.0,
899 ..PricingConfig::default()
900 }),
901 ..EndpointConfig::default()
902 };
903 let p = parse_openrouter_models(&catalog(), "anthropic/claude-3.5-sonnet").unwrap();
904 apply_pricing(&mut ec, &p);
905 let pricing = ec.pricing.unwrap();
906 assert!(
907 (pricing.input_per_1m_tokens - 9.0).abs() < 1e-9,
908 "explicit wins"
909 );
910 assert!(
911 (pricing.output_per_1m_tokens - 15.0).abs() < 1e-9,
912 "fetched fills gap"
913 );
914 assert!(pricing.cached_input_per_1m_tokens.is_some());
915 assert!(pricing.cache_write_per_1m_tokens.is_some());
916 }
917}