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/// Outcome of an automatic pricing pass.
173#[derive(Debug, Default)]
174pub struct PricingOutcome {
175    /// Number of endpoints whose pricing was filled.
176    pub priced: usize,
177    /// Endpoint names where auto pricing was on but the provider has no
178    /// supported exact source; their `pricing_source` is set to `off`.
179    pub unsupported: Vec<String>,
180    /// First lookup error (non-fatal), if any.
181    pub error: Option<String>,
182}
183
184/// Whether pricing is missing on an endpoint (nothing to fill = no warning).
185fn 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
191/// Whether the endpoint is in auto mode (unset or `"auto"`).
192fn 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/// Applies automatic pricing to every endpoint.
199///
200/// Pricing lookup is on by default (`pricing_source` unset = `"auto"`). The
201/// source is inferred from the provider: Bedrock endpoints use the AWS Price
202/// List and `openrouter.ai` URLs use the `OpenRouter` models API. Endpoints
203/// whose provider has no supported source are reported in
204/// [`PricingOutcome::unsupported`] and have `pricing_source` set to `off`.
205/// Lookup failures are collected in [`PricingOutcome::error`] and never abort
206/// the pass.
207#[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    // Fetched once per region/endpoint-kind and reused.
214    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(&region) {
238                    match fetch_bedrock_catalog(client, &region).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[&region];
249                lookup_bedrock(catalog, &model)
250                    .ok_or_else(|| format!("model `{model}` not found in Bedrock pricing"))
251            }
252            None => {
253                // Auto is on but this provider exposes no exact price source:
254                // warn (when there is pricing to fill) and switch it off.
255                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/// Resolves the region whose Bedrock price list applies to an endpoint:
276/// the endpoint's explicit `aws.region`, else `AWS_REGION`/`AWS_DEFAULT_REGION`,
277/// else `us-east-1`.
278#[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
289/// Base host of the public AWS Price List (always `us-east-1`, global).
290const PRICING_HOST: &str = "https://pricing.us-east-1.amazonaws.com";
291
292/// AWS Price List offers that together cover all Bedrock models: AWS-published
293/// models (Nova, Llama, Mistral, …) and Marketplace models (Anthropic, …).
294const BEDROCK_OFFERS: [&str; 2] = ["AmazonBedrock", "AmazonBedrockFoundationModels"];
295
296/// Fetches and merges the Bedrock price catalogs for `region` into a map keyed
297/// by the normalized model name.
298///
299/// # Errors
300///
301/// Returns a description when either offer cannot be fetched or the region is
302/// absent from its region index.
303async 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        // The FoundationModels (Marketplace) offer wins on conflicts: it is
311        // the current source for Anthropic and carries cache write prices.
312        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
321/// Fetches one offer's price file for `region` by following its region index.
322async 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/// Role of a price entry within a model.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352enum Role {
353    /// Ordinary input (prompt) tokens.
354    Input,
355    /// Output (completion) tokens.
356    Output,
357    /// Prompt cache read (cached input) tokens.
358    Read,
359    /// Prompt cache write (cache creation) tokens.
360    Write,
361}
362
363/// Per-model accumulator while merging the two offers.
364#[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
395/// Parses one offer document into `acc`, keyed by normalized model name.
396fn 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
421/// Extracts the model name and price role from a product's attributes, for
422/// either offer shape. Returns `None` for non-standard tiers (batch, flex,
423/// priority, global, latency-optimized, provisioned throughput).
424fn bedrock_entry(attrs: &serde_json::Value) -> Option<(&str, Role)> {
425    // Offer 1 (`AmazonBedrock`): a non-empty `model` attribute, role in
426    // `inferenceType`, restricted to on-demand inference.
427    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    // Offer 2 (`AmazonBedrockFoundationModels`): model in `servicename`
438    // (always suffixed `(Amazon Bedrock Edition)`), role in `usagetype`.
439    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
450/// Lowercases and drops separators so hyphen/underscore/camel spellings match.
451fn normalize_token(value: &str) -> String {
452    value
453        .chars()
454        .filter(char::is_ascii_alphanumeric)
455        .collect::<String>()
456        .to_ascii_lowercase()
457}
458
459/// Maps an `AmazonBedrock` `inferenceType` to a role, excluding tier variants.
460fn 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
485/// Maps an `AmazonBedrockFoundationModels` `usagetype` to a role, excluding
486/// tier/TTL variants.
487fn 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
510/// Reads the first `OnDemand` price and its unit for a SKU.
511fn 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
523/// Converts a price to USD per 1M tokens from its unit string.
524fn 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
537/// Inference-profile prefixes on Bedrock model ids (`us.`, `global.`, …).
538const MODEL_PREFIXES: [&str; 12] = [
539    "us", "eu", "apac", "global", "us-gov", "ca", "sa", "me", "af", "il", "ap", "gov",
540];
541
542/// Provider prefixes on Bedrock model ids (`anthropic.`, `amazon.`, …).
543const 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/// Normalizes a model id or display name to a compact comparison key.
568///
569/// Strips inference-profile prefixes (`us.`, `global.`, …) and provider
570/// prefixes (`anthropic.`, `amazon.`, …), the `:0` revision suffix, 8-digit
571/// date tokens, and all separators, so `us.anthropic.claude-3-5-sonnet-
572/// 20241022-v2:0` and `Claude 3.5 Sonnet v2` both become `claude35sonnetv2`.
573#[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/// Removes separators and 8-digit date tokens, lowercasing the result.
590#[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/// Finds the catalog entry matching `model`: exact key first, then the closest
606/// prefix match, then a legacy fallback that drops a trailing `vN`.
607#[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
627/// Closest catalog key where one key is a prefix of the other.
628fn 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
643/// Drops a trailing `v<digits>` from a compact key (`claudev2` → `claude`).
644fn 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    /// Builds a Bedrock catalog from synthetic offer documents.
664    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        // Standard on-demand input/output only; the flex variant must not win.
752        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        // The 1h TTL variant must not overwrite the default (5m) cache write.
768        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    /// Live check against the real AWS Price List (network). Run with:
781    /// `cargo test --all-features bedrock_live_prices -- --ignored`
782    #[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        // Unset -> inferred from the endpoint.
813        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        // Explicit off wins even for a Bedrock endpoint.
829        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}