Skip to main content

llm_browser_testkit/
pricing.rs

1//! Automatic pricing lookup from providers that expose exact, public
2//! per-token prices.
3//!
4//! Two sources are supported:
5//!
6//! - `OpenRouter`: `GET /api/v1/models` carries exact per-token `prompt`,
7//!   `completion`, `input_cache_read` and `input_cache_write` prices with no
8//!   authentication.
9//! - AWS Bedrock: the public AWS Price List offers `AmazonBedrock` (Nova,
10//!   Llama, Mistral, …) and `AmazonBedrockFoundationModels` (Anthropic
11//!   Claude, Cohere, …) publish exact per-model input/output/cache-read/
12//!   cache-write prices per region. Both are fetched at startup and mapped to
13//!   the configured model id.
14//!
15//! Other providers either expose no public price API (`OpenAI`, Groq, xAI,
16//! `DeepSeek`, Google) or expose prices that cannot be mapped to a model
17//! exactly without heuristics (Azure Retail Prices).
18
19use std::collections::HashMap;
20
21use crate::scenario::{EndpointConfig, Provider};
22
23/// Exact per-1M-token prices for a model.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ModelPricing {
26    /// Input (prompt) price per 1M tokens.
27    pub input_per_1m: f64,
28    /// Output (completion) price per 1M tokens.
29    pub output_per_1m: f64,
30    /// Cached-input (cache read) price per 1M tokens, when offered.
31    pub cached_input_per_1m: Option<f64>,
32    /// Cache-write (cache creation) price per 1M tokens, when offered.
33    pub cache_write_per_1m: Option<f64>,
34}
35
36/// Parses an `OpenRouter` `/api/v1/models` response for `model`.
37///
38/// Matches the full model id case-insensitively first, then falls back to the
39/// leaf after the last `/` (so `claude-3.5-sonnet` matches
40/// `anthropic/claude-3.5-sonnet`). Returns `None` when the model is absent or
41/// its pricing is not a usable positive number.
42#[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
60/// Reads a per-token price (string or number) and converts it to per-1M
61/// tokens. Negative values (`OpenRouter` uses `-1` for dynamic/auto prices)
62/// and missing keys yield `None`.
63fn 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
72/// Fetches exact `OpenRouter` pricing for `model`.
73///
74/// # Errors
75///
76/// Returns a description when the request fails, the response is not JSON, or
77/// the model is not present in the catalog.
78pub 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
87/// Fetches the raw `OpenRouter` models catalog (one HTTP call, reusable for
88/// many models).
89///
90/// # Errors
91///
92/// Returns a description when the request fails or the response is not JSON.
93async 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/// Which exact-pricing source an endpoint opts into.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum Source {
113    /// `OpenRouter` public models API.
114    OpenRouter,
115    /// AWS Bedrock Price List (`AmazonBedrock` + `AmazonBedrockFoundationModels`).
116    Bedrock,
117}
118
119/// Resolves the pricing source for an endpoint.
120///
121/// `pricing_source` is `"openrouter"`, `"bedrock"`, `"auto"`, or one of
122/// `"off"`/`"none"`/`"disabled"`. When **unset** (the default) or `"auto"`,
123/// the source is inferred: Bedrock endpoints use the Bedrock Price List and
124/// `openrouter.ai` URLs use the `OpenRouter` models API; anything else is a
125/// no-op.
126#[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/// Infers the pricing source from the endpoint when `pricing_source` is unset
138/// or `"auto"`.
139#[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
154/// Fills unset pricing fields on `ec` from fetched `pricing`; explicit values
155/// win.
156pub 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/// Applies automatic pricing to every endpoint that opts in.
173///
174/// Returns the number of endpoints priced. Endpoints without a model, without
175/// a supported source, or whose lookup fails are left untouched. The first
176/// failure is returned as an error string while the remaining endpoints are
177/// still processed, so a single unknown model never blocks a run.
178///
179/// # Errors
180///
181/// Returns the first lookup error when **no** endpoint could be priced; a
182/// partial success returns `Ok` with the number priced.
183#[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    // Fetched once per region/endpoint-kind and reused.
191    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(&region) {
215                    match fetch_bedrock_catalog(client, &region).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[&region];
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/// Resolves the region whose Bedrock price list applies to an endpoint:
248/// the endpoint's explicit `aws.region`, else `AWS_REGION`/`AWS_DEFAULT_REGION`,
249/// else `us-east-1`.
250#[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
261/// Base host of the public AWS Price List (always `us-east-1`, global).
262const PRICING_HOST: &str = "https://pricing.us-east-1.amazonaws.com";
263
264/// AWS Price List offers that together cover all Bedrock models: AWS-published
265/// models (Nova, Llama, Mistral, …) and Marketplace models (Anthropic, …).
266const BEDROCK_OFFERS: [&str; 2] = ["AmazonBedrock", "AmazonBedrockFoundationModels"];
267
268/// Fetches and merges the Bedrock price catalogs for `region` into a map keyed
269/// by the normalized model name.
270///
271/// # Errors
272///
273/// Returns a description when either offer cannot be fetched or the region is
274/// absent from its region index.
275async 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        // The FoundationModels (Marketplace) offer wins on conflicts: it is
283        // the current source for Anthropic and carries cache write prices.
284        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
293/// Fetches one offer's price file for `region` by following its region index.
294async 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/// Role of a price entry within a model.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324enum Role {
325    /// Ordinary input (prompt) tokens.
326    Input,
327    /// Output (completion) tokens.
328    Output,
329    /// Prompt cache read (cached input) tokens.
330    Read,
331    /// Prompt cache write (cache creation) tokens.
332    Write,
333}
334
335/// Per-model accumulator while merging the two offers.
336#[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
367/// Parses one offer document into `acc`, keyed by normalized model name.
368fn 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
393/// Extracts the model name and price role from a product's attributes, for
394/// either offer shape. Returns `None` for non-standard tiers (batch, flex,
395/// priority, global, latency-optimized, provisioned throughput).
396fn bedrock_entry(attrs: &serde_json::Value) -> Option<(&str, Role)> {
397    // Offer 1 (`AmazonBedrock`): a non-empty `model` attribute, role in
398    // `inferenceType`, restricted to on-demand inference.
399    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    // Offer 2 (`AmazonBedrockFoundationModels`): model in `servicename`
410    // (always suffixed `(Amazon Bedrock Edition)`), role in `usagetype`.
411    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
422/// Lowercases and drops separators so hyphen/underscore/camel spellings match.
423fn normalize_token(value: &str) -> String {
424    value
425        .chars()
426        .filter(char::is_ascii_alphanumeric)
427        .collect::<String>()
428        .to_ascii_lowercase()
429}
430
431/// Maps an `AmazonBedrock` `inferenceType` to a role, excluding tier variants.
432fn 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
457/// Maps an `AmazonBedrockFoundationModels` `usagetype` to a role, excluding
458/// tier/TTL variants.
459fn 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
482/// Reads the first `OnDemand` price and its unit for a SKU.
483fn 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
495/// Converts a price to USD per 1M tokens from its unit string.
496fn 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
509/// Inference-profile prefixes on Bedrock model ids (`us.`, `global.`, …).
510const MODEL_PREFIXES: [&str; 12] = [
511    "us", "eu", "apac", "global", "us-gov", "ca", "sa", "me", "af", "il", "ap", "gov",
512];
513
514/// Provider prefixes on Bedrock model ids (`anthropic.`, `amazon.`, …).
515const 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/// Normalizes a model id or display name to a compact comparison key.
540///
541/// Strips inference-profile prefixes (`us.`, `global.`, …) and provider
542/// prefixes (`anthropic.`, `amazon.`, …), the `:0` revision suffix, 8-digit
543/// date tokens, and all separators, so `us.anthropic.claude-3-5-sonnet-
544/// 20241022-v2:0` and `Claude 3.5 Sonnet v2` both become `claude35sonnetv2`.
545#[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/// Removes separators and 8-digit date tokens, lowercasing the result.
562#[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/// Finds the catalog entry matching `model`: exact key first, then the closest
578/// prefix match, then a legacy fallback that drops a trailing `vN`.
579#[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
599/// Closest catalog key where one key is a prefix of the other.
600fn 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
615/// Drops a trailing `v<digits>` from a compact key (`claudev2` → `claude`).
616fn 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    /// Builds a Bedrock catalog from synthetic offer documents.
636    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        // Standard on-demand input/output only; the flex variant must not win.
724        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        // The 1h TTL variant must not overwrite the default (5m) cache write.
740        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    /// Live check against the real AWS Price List (network). Run with:
753    /// `cargo test --all-features bedrock_live_prices -- --ignored`
754    #[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        // Unset -> inferred from the endpoint.
785        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        // Explicit off wins even for a Bedrock endpoint.
801        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}