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