use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::Deserialize;
use tokio::sync::Mutex;
use crate::error::{MiniLLMError, Result};
use crate::generator::GeneratorInfo;
use crate::provider::wire::TokenPrice;
const BASE_URL: &str = "https://openrouter.ai/api/v1";
const TTL: Duration = Duration::from_secs(3600);
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq)]
pub struct ModelRates {
pub price: TokenPrice,
pub max_completion_tokens: Option<u32>,
pub context_length: u32,
}
#[derive(Debug)]
pub(crate) struct CachedPrices {
model: String,
endpoints: Vec<PricedEndpoint>,
fetched_at: Instant,
}
pub(crate) type PriceCache = Arc<Mutex<Option<CachedPrices>>>;
impl GeneratorInfo {
pub async fn model_rates(&self) -> Result<ModelRates> {
self.model_rates_served_by(Some(&self.catalog_provider())).await
}
pub fn pricing_key(&self) -> String {
format!("{}:{}", self.catalog_provider(), self.catalog_model())
}
fn catalog_model(&self) -> &str {
self.openrouter_name.as_deref().unwrap_or(&self.model)
}
fn catalog_provider(&self) -> String {
self.provider
.openrouter_slug()
.map(str::to_string)
.unwrap_or_else(|| self.name.to_lowercase())
}
pub async fn model_rates_served_by(&self, provider: Option<&str>) -> Result<ModelRates> {
self.rates_with(provider, |model| fetch_endpoints(self.client(), model)).await
}
#[cfg(feature = "estimate")]
pub async fn estimate_cost_usd(&self,
messages: &[crate::message::Message],
params: &super::CompletionParameters,
) -> Result<f64> {
let rates = self.model_rates().await?;
Ok(crate::provider::estimate_cost_usd(messages, params, &rates))
}
async fn rates_with<F>(
&self,
provider: Option<&str>,
fetch: impl FnOnce(String) -> F,
) -> Result<ModelRates>
where
F: std::future::Future<Output = Result<Vec<PricedEndpoint>>>,
{
let model = self.catalog_model();
validate_model_id(model)?;
let mut cached = self.prices.lock().await;
let stale = cached
.as_ref()
.is_none_or(|c| c.model != model || c.fetched_at.elapsed() >= TTL);
if stale {
let endpoints = fetch(model.to_string()).await?;
*cached = Some(CachedPrices {
model: model.to_string(),
endpoints,
fetched_at: Instant::now(),
});
}
let prices = cached.as_ref().expect("populated above or returned early");
select(&prices.endpoints, model, provider)
}
}
async fn fetch_endpoints(
client: crate::provider::LLMClient,
model: String,
) -> Result<Vec<PricedEndpoint>> {
debug_assert!(validate_model_id(&model).is_ok(), "callers validate first");
let response = client
.http()
.get(format!("{BASE_URL}/models/{model}/endpoints"))
.timeout(FETCH_TIMEOUT)
.send()
.await
.map_err(|e| match e {
reqwest_middleware::Error::Reqwest(e) => MiniLLMError::Http(e),
reqwest_middleware::Error::Middleware(e) => MiniLLMError::InvalidParameter(format!(
"injected client refused the price-catalog read: {e:#}"
)),
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(MiniLLMError::InvalidParameter(format!(
"model {model:?} is not in OpenRouter's catalog, so it cannot be priced"
)));
}
let body: EndpointsResponse = response.error_for_status()?.json().await?;
price_endpoints(body.data.endpoints)
}
#[derive(Deserialize)]
struct EndpointsResponse {
data: EndpointsData,
}
#[derive(Deserialize)]
struct EndpointsData {
endpoints: Vec<Endpoint>,
}
#[derive(Deserialize)]
struct Endpoint {
tag: String,
pricing: Pricing,
#[serde(default)]
max_completion_tokens: Option<u32>,
#[serde(default)]
context_length: Option<u32>,
}
impl Endpoint {
fn provider_slug(&self) -> &str {
self.tag.split('/').next().unwrap_or(&self.tag)
}
}
#[derive(Deserialize)]
struct Pricing {
prompt: String,
completion: String,
#[serde(default)]
input_cache_read: Option<String>,
#[serde(default)]
input_cache_write: Option<String>,
#[serde(default)]
audio: Option<String>,
#[serde(default)]
image: Option<String>,
}
fn per_mtok(field: &str, raw: &str) -> Result<f64> {
let malformed = |why: &str| {
MiniLLMError::MalformedResponse(format!("model catalog: {field} rate {raw:?} {why}"))
};
let parsed = raw.parse::<f64>().map_err(|_| malformed("is not a number"))?;
if !parsed.is_finite() {
return Err(malformed("is not finite"));
}
if parsed < 0.0 {
return Err(malformed("is negative"));
}
Ok(parsed * 1_000_000.0)
}
impl Endpoint {
fn rates(&self) -> Result<ModelRates> {
let mut price = TokenPrice::new(
per_mtok("prompt", &self.pricing.prompt)?,
per_mtok("completion", &self.pricing.completion)?,
);
match (&self.pricing.input_cache_read, &self.pricing.input_cache_write) {
(Some(r), Some(w)) => {
price = price
.with_cache_rates(per_mtok("input_cache_read", r)?, per_mtok("input_cache_write", w)?)
}
(Some(r), None) => price.cache_read_per_mtok = Some(per_mtok("input_cache_read", r)?),
_ => {}
}
price = price.with_media_rates(
self.pricing.audio.as_deref().map(|r| per_mtok("audio", r)).transpose()?,
self.pricing.image.as_deref().map(|r| per_mtok("image", r)).transpose()?,
);
let context_length = self.context_length.ok_or_else(|| {
MiniLLMError::MalformedResponse(format!("model catalog: endpoint {} has no context_length", self.tag))
})?;
Ok(ModelRates { price, max_completion_tokens: self.max_completion_tokens, context_length })
}
}
fn max_rate(a: Option<f64>, b: Option<f64>) -> Option<f64> {
match (a, b) {
(Some(x), Some(y)) => Some(x.max(y)),
(some, None) | (None, some) => some,
}
}
#[derive(Debug, Clone)]
struct PricedEndpoint {
provider_slug: String,
rates: ModelRates,
}
fn price_endpoints(endpoints: Vec<Endpoint>) -> Result<Vec<PricedEndpoint>> {
endpoints
.into_iter()
.map(|endpoint| {
Ok(PricedEndpoint {
provider_slug: endpoint.provider_slug().to_string(),
rates: endpoint.rates()?,
})
})
.collect()
}
fn select(
endpoints: &[PricedEndpoint],
model: &str,
provider: Option<&str>,
) -> Result<ModelRates> {
let matches_provider = |e: &&PricedEndpoint| match provider {
Some(slug) => e.provider_slug.eq_ignore_ascii_case(slug),
None => true,
};
let candidates: Vec<&PricedEndpoint> = match endpoints.iter().filter(matches_provider).collect::<Vec<_>>() {
served if !served.is_empty() => served,
_ => endpoints.iter().collect(),
};
let mut worst: Option<ModelRates> = None;
for endpoint in candidates {
let rates = endpoint.rates.clone();
let price = &rates.price;
let (input, output) = (price.input_per_mtok, price.output_per_mtok);
let effective = TokenPrice {
input_per_mtok: input,
output_per_mtok: output,
cache_read_per_mtok: Some(price.cache_read_per_mtok.unwrap_or(input)),
cache_write_per_mtok: Some(price.cache_write_per_mtok.unwrap_or(input)),
audio_per_mtok: Some(price.audio_rate()),
image_per_mtok: Some(price.image_rate()),
};
worst = Some(match worst {
None => ModelRates { price: effective, ..rates },
Some(w) => ModelRates {
price: TokenPrice {
input_per_mtok: w.price.input_per_mtok.max(input),
output_per_mtok: w.price.output_per_mtok.max(output),
cache_read_per_mtok: max_rate(w.price.cache_read_per_mtok, effective.cache_read_per_mtok),
cache_write_per_mtok: max_rate(w.price.cache_write_per_mtok, effective.cache_write_per_mtok),
audio_per_mtok: max_rate(w.price.audio_per_mtok, effective.audio_per_mtok),
image_per_mtok: max_rate(w.price.image_per_mtok, effective.image_per_mtok),
},
max_completion_tokens: match (w.max_completion_tokens, rates.max_completion_tokens) {
(None, _) | (_, None) => None,
(Some(a), Some(b)) => Some(a.max(b)),
},
context_length: w.context_length.max(rates.context_length),
},
});
}
worst.ok_or_else(|| {
MiniLLMError::InvalidParameter(format!(
"model {model:?} has no endpoints, so it cannot be priced"
))
})
}
fn is_model_id_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '/')
}
fn validate_model_id(model: &str) -> Result<()> {
let refuse = |why: &str| {
Err(MiniLLMError::InvalidParameter(format!("model id {model:?} {why}, so it cannot be priced")))
};
if model.is_empty() {
return refuse("is empty");
}
if let Some(bad) = model.chars().find(|c| !is_model_id_char(*c)) {
return refuse(&format!("contains {bad:?}, which no model id contains"));
}
if model.split('/').any(|segment| segment.is_empty() || segment == "." || segment == "..") {
return refuse("has an empty or dotted path segment");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Result<Vec<PricedEndpoint>> {
price_endpoints(serde_json::from_str::<EndpointsResponse>(json).unwrap().data.endpoints)
}
fn endpoints(json: &str) -> Vec<PricedEndpoint> {
parse(json).expect("fixture is well formed")
}
#[track_caller]
fn assert_rate(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-9, "rate {actual} is not {expected}");
}
const SPREAD: &str = r#"{"data":{"endpoints":[
{"tag":"ambient","context_length":128000,"max_completion_tokens":8192,
"pricing":{"prompt":"0.00000054","completion":"0.00000176"}},
{"tag":"fireworks","context_length":128000,"max_completion_tokens":4096,
"pricing":{"prompt":"0.0000021","completion":"0.0000066"}},
{"tag":"wafer","context_length":64000,"max_completion_tokens":2048,
"pricing":{"prompt":"0.000003","completion":"0.00001025"}}
]}}"#;
#[test]
fn an_unpinned_provider_is_priced_at_the_dearest_rate_of_any_endpoint() {
let rates = select(&endpoints(SPREAD), "z-ai/glm-5.2", None).unwrap();
assert_rate(rates.price.input_per_mtok, 3.0);
assert_rate(rates.price.output_per_mtok, 10.25);
assert_eq!(rates.max_completion_tokens, Some(8192));
assert_eq!(rates.context_length, 128_000);
}
#[test]
fn each_rate_is_bounded_independently_of_the_others() {
let json = r#"{"data":{"endpoints":[
{"tag":"cheap-text-dear-audio","context_length":1000,
"pricing":{"prompt":"0.000001","completion":"0.000002","audio":"0.0001"}},
{"tag":"dear-text-cheap-audio","context_length":1000,
"pricing":{"prompt":"0.000005","completion":"0.000009","audio":"0.000005"}}
]}}"#;
let rates = select(&endpoints(json), "m", None).unwrap();
assert_rate(rates.price.input_per_mtok, 5.0);
assert_rate(rates.price.output_per_mtok, 9.0);
assert_rate(rates.price.audio_rate(), 100.0);
}
#[test]
fn a_pinned_provider_is_priced_at_that_providers_endpoint() {
let rates = select(&endpoints(SPREAD), "z-ai/glm-5.2", Some("fireworks")).unwrap();
assert_rate(rates.price.input_per_mtok, 2.1);
assert_rate(rates.price.output_per_mtok, 6.6);
assert_eq!(rates.max_completion_tokens, Some(4096));
}
#[test]
fn a_provider_owning_several_endpoints_is_priced_at_its_dearest() {
let json = r#"{"data":{"endpoints":[
{"tag":"amazon-bedrock/us-east-1","context_length":200000,
"pricing":{"prompt":"0.0000022","completion":"0.000011"}},
{"tag":"amazon-bedrock/global","context_length":200000,
"pricing":{"prompt":"0.000002","completion":"0.00001"}},
{"tag":"anthropic","context_length":200000,
"pricing":{"prompt":"0.000002","completion":"0.00001"}}
]}}"#;
let bedrock =
select(&endpoints(json), "anthropic/claude-sonnet-5", Some("amazon-bedrock")).unwrap();
assert_rate(bedrock.price.output_per_mtok, 11.0);
let direct =
select(&endpoints(json), "anthropic/claude-sonnet-5", Some("anthropic")).unwrap();
assert_rate(direct.price.output_per_mtok, 10.0);
}
#[test]
fn a_provider_that_does_not_serve_the_model_prices_at_the_dearest_of_all() {
let fallback = select(&endpoints(SPREAD), "z-ai/glm-5.2", Some("anthropic")).unwrap();
let dearest = select(&endpoints(SPREAD), "z-ai/glm-5.2", None).unwrap();
assert_eq!(fallback, dearest);
}
#[test]
fn a_provider_name_matches_its_slug_regardless_of_case() {
let pinned = select(&endpoints(SPREAD), "z-ai/glm-5.2", Some("Fireworks")).unwrap();
assert_rate(pinned.price.input_per_mtok, 2.1);
}
#[test]
fn per_token_strings_become_per_million_rates_with_their_cache_buckets() {
let json = r#"{"data":{"endpoints":[{"tag":"anthropic","context_length":1000000,
"max_completion_tokens":128000,
"pricing":{"prompt":"0.000003","completion":"0.000015",
"input_cache_read":"0.0000003","input_cache_write":"0.00000375"}}]}}"#;
let rates = select(&endpoints(json), "m", None).unwrap();
assert_rate(rates.price.input_per_mtok, 3.0);
assert_rate(rates.price.output_per_mtok, 15.0);
assert_rate(rates.price.cache_read_per_mtok.unwrap(), 0.3);
assert_rate(rates.price.cache_write_per_mtok.unwrap(), 3.75);
}
#[test]
fn a_read_only_cache_resolves_its_write_rate_to_the_input_rate() {
let json = r#"{"data":{"endpoints":[{"tag":"openai","context_length":400000,
"pricing":{"prompt":"0.000005","completion":"0.00003","input_cache_read":"0.0000005"}}]}}"#;
let rates = select(&endpoints(json), "openai/gpt-5.5", None).unwrap();
assert_rate(rates.price.cache_read_per_mtok.unwrap(), 0.5);
assert_rate(rates.price.cache_write_per_mtok.unwrap(), 5.0);
assert_rate(rates.price.image_rate(), 5.0);
assert_rate(rates.price.audio_rate(), 5.0 * crate::provider::wire::AUDIO_RATE_FALLBACK_MULTIPLE);
let usage = crate::provider::Usage { cache_write_tokens: 1_000_000, ..Default::default() };
assert!((rates.price.cost_of(&usage) - 5.0).abs() < 1e-9);
}
#[test]
fn an_endpoint_omitting_its_cache_discount_raises_the_bound() {
let json = r#"{"data":{"endpoints":[
{"tag":"discounted","context_length":1000,
"pricing":{"prompt":"0.000005","completion":"0.00001","input_cache_read":"0.0000005"}},
{"tag":"no-discount","context_length":1000,
"pricing":{"prompt":"0.000005","completion":"0.00001"}}
]}}"#;
let rates = select(&endpoints(json), "m", None).unwrap();
assert_rate(rates.price.cache_read_per_mtok.unwrap(), 5.0);
}
#[test]
fn an_endpoint_with_no_completion_cap_keeps_its_context_window() {
let json = r#"{"data":{"endpoints":[{"tag":"x","context_length":32768,
"pricing":{"prompt":"0.0000001","completion":"0.0000002"}}]}}"#;
let rates = select(&endpoints(json), "some/model", None).unwrap();
assert_eq!(rates.max_completion_tokens, None);
assert_eq!(rates.context_length, 32_768);
}
#[test]
fn a_rate_that_is_not_a_finite_non_negative_number_fails_the_fetch() {
for bad in ["free", "NaN", "nan", "inf", "-inf", "infinity", "1e400", "-1", "-0.5"] {
let json = format!(
r#"{{"data":{{"endpoints":[{{"tag":"x","context_length":1000,
"pricing":{{"prompt":"{bad}","completion":"0.000001"}}}}]}}}}"#
);
let Err(err) = parse(&json) else {
panic!("rate {bad:?} was accepted; it must fail loudly");
};
assert!(matches!(err, MiniLLMError::MalformedResponse(_)), "{bad:?}: {err:?}");
}
}
#[test]
fn a_nan_rate_never_lets_a_cheaper_sibling_set_the_bound() {
let json = r#"{"data":{"endpoints":[
{"tag":"cheap","context_length":1000,
"pricing":{"prompt":"0.000001","completion":"0.000002"}},
{"tag":"dear","context_length":1000,
"pricing":{"prompt":"NaN","completion":"0.00001"}}
]}}"#;
assert!(parse(json).is_err(), "one poisoned endpoint condemns the model");
}
fn fake_fetch(calls: &std::sync::atomic::AtomicUsize) -> Result<Vec<PricedEndpoint>> {
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
parse(SPREAD)
}
fn generator() -> GeneratorInfo {
GeneratorInfo::openrouter("z-ai/glm-5.2")
}
#[test]
fn the_pricing_key_names_the_provider_and_the_catalog_model() {
let direct = GeneratorInfo::anthropic("claude-haiku-4-5-20251001")
.with_openrouter_name("anthropic/claude-haiku-4.5");
assert_eq!(direct.pricing_key(), "anthropic:anthropic/claude-haiku-4.5");
let routed = GeneratorInfo::openrouter("anthropic/claude-haiku-4.5");
assert_eq!(routed.pricing_key(), "openrouter:anthropic/claude-haiku-4.5");
assert_ne!(direct.pricing_key(), routed.pricing_key());
assert_eq!(routed.pricing_key(), GeneratorInfo::openrouter("anthropic/claude-haiku-4.5").pricing_key());
}
#[tokio::test]
async fn a_fresh_price_is_served_from_the_cache() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let generator = generator();
for _ in 0..3 {
generator
.rates_with(None, |_| async { fake_fetch(&calls) })
.await
.expect("the fixture prices");
}
assert_eq!(calls.load(Ordering::SeqCst), 1, "three lookups, one fetch");
}
#[tokio::test]
async fn a_clone_shares_the_cache() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let generator = generator();
let clone = generator.clone();
generator
.rates_with(None, |_| async { fake_fetch(&calls) })
.await
.expect("the fixture prices");
clone
.rates_with(None, |_| async { fake_fetch(&calls) })
.await
.expect("served from the shared cache");
assert_eq!(calls.load(Ordering::SeqCst), 1, "the clone reused the fetch");
}
#[tokio::test]
async fn two_selections_over_one_generator_share_one_fetch() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let generator = generator();
let any = generator.rates_with(None, |_| async { fake_fetch(&calls) }).await.unwrap();
let pinned = generator
.rates_with(Some("fireworks"), |_| async { fake_fetch(&calls) })
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1, "one fetch serves both selections");
assert_rate(any.price.output_per_mtok, 10.25);
assert_rate(pinned.price.output_per_mtok, 6.6);
}
#[tokio::test]
async fn a_failed_first_fetch_leaves_the_slot_empty_and_the_next_caller_retries() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let generator = generator();
let failed = generator
.rates_with(None, |_| async {
calls.fetch_add(1, Ordering::SeqCst);
Err(MiniLLMError::Timeout)
})
.await;
assert!(failed.is_err(), "the error surfaces rather than a zero price");
generator
.rates_with(None, |_| async { fake_fetch(&calls) })
.await
.expect("the retry succeeds");
assert_eq!(calls.load(Ordering::SeqCst), 2, "the failure was not cached");
}
#[tokio::test]
async fn a_failed_refetch_keeps_the_entry_the_cache_is_still_fresh_for() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let mut generator = generator();
generator.rates_with(None, |_| async { fake_fetch(&calls) }).await.unwrap();
generator.model = "openai/gpt-5.5".to_string();
let failed = generator
.rates_with(None, |_| async {
calls.fetch_add(1, Ordering::SeqCst);
Err(MiniLLMError::Timeout)
})
.await;
assert!(failed.is_err(), "the error surfaces");
generator.model = "z-ai/glm-5.2".to_string();
let rates = generator.rates_with(None, |_| async { fake_fetch(&calls) }).await.unwrap();
assert_rate(rates.price.output_per_mtok, 10.25);
assert_eq!(calls.load(Ordering::SeqCst), 2, "the survivor was served, not refetched");
generator.model = "openai/gpt-5.5".to_string();
let other = generator
.rates_with(None, |_| async {
calls.fetch_add(1, Ordering::SeqCst);
parse(
r#"{"data":{"endpoints":[{"tag":"openai","context_length":400000,
"pricing":{"prompt":"0.000005","completion":"0.00003"}}]}}"#,
)
})
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 3, "the unpriced model refetched");
assert_rate(other.price.output_per_mtok, 30.0);
}
#[tokio::test]
async fn changing_the_model_invalidates_the_cache() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let mut generator = generator();
generator.rates_with(None, |_| async { fake_fetch(&calls) }).await.unwrap();
generator.model = "openai/gpt-5.5".to_string();
generator.rates_with(None, |_| async { fake_fetch(&calls) }).await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2, "a different model is a different fetch");
}
#[tokio::test]
async fn a_slow_fetch_on_one_generator_does_not_block_another() {
let slow_gen = generator();
let quick_gen = GeneratorInfo::openrouter("openai/gpt-5.5");
let (release, released) = tokio::sync::oneshot::channel::<()>();
let slow = slow_gen.rates_with(None, |_| async move {
released.await.expect("the other lookup finishes first");
parse(SPREAD)
});
let quick = async {
let rates = quick_gen
.rates_with(None, |_| async { parse(SPREAD) })
.await
.expect("an unrelated generator prices while the slow fetch is in flight");
release.send(()).expect("the slow fetch is still waiting");
rates
};
let both = tokio::time::timeout(Duration::from_secs(5), futures::future::join(slow, quick));
let (slow, _quick) = both.await.expect("neither lookup blocked the other");
assert!(slow.is_ok());
}
#[test]
fn an_id_that_is_not_a_model_id_is_refused() {
let hostile = [
("evil?free=1", "a query would swallow the /endpoints suffix"),
("x#frag", "a fragment would truncate the path"),
("../../../models", "traversal would climb out of the api version"),
("a/../b", "traversal, buried mid-path"),
("a/./b", "a dot segment resolves away"),
("a//b", "an empty segment collapses"),
("a b", "a space is not a model id character"),
("a\nb", "nor is a newline"),
("a%2fb", "nor is a percent escape"),
("", "an empty id names nothing"),
];
for (bad, why) in hostile {
let Err(err) = validate_model_id(bad) else {
panic!("{bad:?} was accepted, but {why}");
};
assert!(matches!(err, MiniLLMError::InvalidParameter(_)), "{bad:?}: {err:?}");
}
}
#[test]
fn a_real_model_id_is_accepted() {
for real in ["anthropic/claude-sonnet-4.6", "openai/gpt-5.5", "o3", "z-ai/glm-5.2"] {
assert!(validate_model_id(real).is_ok(), "{real} is a real id");
}
}
#[test]
fn a_model_with_no_endpoints_at_all_fails_loudly() {
let err = select(&endpoints(r#"{"data":{"endpoints":[]}}"#), "m", None).unwrap_err();
assert!(err.to_string().contains("has no endpoints"), "{err}");
}
#[test]
fn a_region_suffixed_tag_resolves_to_its_provider() {
let e = &endpoints(
r#"{"data":{"endpoints":[{"tag":"google-vertex/europe","context_length":1,
"pricing":{"prompt":"0.1","completion":"0.2"}}]}}"#,
)[0];
assert_eq!(e.provider_slug, "google-vertex");
}
}