use std::time::Duration;
use serde_json::Value;
use crate::llm::{LlmError, ProtocolClient, Usage};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Price {
pub input_per_million: f64,
pub output_per_million: f64,
}
impl Price {
#[must_use]
pub fn cost(&self, usage: Usage) -> f64 {
(tokens(usage.input_tokens) * self.input_per_million
+ tokens(usage.output_tokens) * self.output_per_million)
/ 1_000_000.0
}
}
fn tokens(count: u64) -> f64 {
let high = u32::try_from(count >> 32).unwrap_or(u32::MAX);
let low = u32::try_from(count & u64::from(u32::MAX)).unwrap_or(u32::MAX);
f64::from(high) * 4_294_967_296.0 + f64::from(low)
}
#[must_use]
pub fn find_price(models: &Value, model: &str) -> Option<Price> {
let entries = models
.get("data")
.and_then(Value::as_array)
.or_else(|| models.as_array())?;
let entry = entries
.iter()
.find(|entry| entry.get("id").and_then(Value::as_str) == Some(model))?;
let pricing = entry.get("pricing")?;
let per_million = pricing.get("unit").and_then(Value::as_str) == Some("per_million_tokens");
let rate = |field: &str| -> Option<f64> {
let value = match pricing.get(field)? {
Value::String(text) => text.trim().parse::<f64>().ok()? * 1_000_000.0,
Value::Number(number) if per_million => number.as_f64()?,
_ => return None,
};
(value.is_finite() && value >= 0.0).then_some(value)
};
Some(Price {
input_per_million: rate("prompt")?,
output_per_million: rate("completion")?,
})
}
pub const LISTING_TIMEOUT: Duration = Duration::from_secs(10);
pub async fn fetch_listing(client: &ProtocolClient, cap: Duration) -> Option<Value> {
match tokio::time::timeout(cap, client.models()).await {
Ok(Ok(models)) => Some(models),
Ok(Err(error)) => unavailable(&error),
Err(_) => too_slow(cap),
}
}
#[must_use]
pub fn listed_price(listing: &Value, model: &str) -> Option<Price> {
find_price(listing, model).or_else(|| unlisted(model))
}
fn unlisted(model: &str) -> Option<Price> {
tracing::info!("no price listed for model {model}; showing tokens only");
None
}
fn unavailable(error: &LlmError) -> Option<Value> {
tracing::warn!("cannot read model prices ({error}); showing tokens only");
None
}
fn too_slow(cap: Duration) -> Option<Value> {
tracing::warn!(
"model prices not received within {}s; showing tokens only",
cap.as_secs()
);
None
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn openrouter_prices_are_per_token_strings() {
let models = json!({"data": [{
"id": "anthropic/claude-sonnet-5",
"pricing": {"prompt": "0.000002", "completion": "0.00001", "web_search": "0.01"}
}]});
let price = find_price(&models, "anthropic/claude-sonnet-5");
assert!(
price.is_some_and(
|p| close(p.input_per_million, 2.0) && close(p.output_per_million, 10.0)
)
);
}
#[test]
fn nanogpt_prices_are_per_million_numbers() {
let models = json!({"object": "list", "data": [{
"id": "anthropic/claude-sonnet-5",
"pricing": {"prompt": 2, "completion": 10, "currency": "USD", "unit": "per_million_tokens"}
}]});
let price = find_price(&models, "anthropic/claude-sonnet-5");
assert!(
price.is_some_and(
|p| close(p.input_per_million, 2.0) && close(p.output_per_million, 10.0)
)
);
}
#[test]
fn unknown_shapes_have_no_price() {
let openai = json!({"object": "list", "data": [{"id": "gpt-x", "object": "model", "created": 1, "owned_by": "openai"}]});
assert_eq!(find_price(&openai, "gpt-x"), None);
let numbers_without_unit =
json!({"data": [{"id": "m", "pricing": {"prompt": 2, "completion": 10}}]});
assert_eq!(find_price(&numbers_without_unit, "m"), None);
let router = json!({"data": [{"id": "openrouter/auto", "pricing": {"prompt": "-1", "completion": "-1"}}]});
assert_eq!(find_price(&router, "openrouter/auto"), None);
let other_model =
json!({"data": [{"id": "a", "pricing": {"prompt": "0.1", "completion": "0.1"}}]});
assert_eq!(find_price(&other_model, "b"), None);
}
#[test]
fn cost_multiplies_tokens_by_price() {
let price = Price {
input_per_million: 2.0,
output_per_million: 10.0,
};
let usage = Usage {
input_tokens: 500_000,
output_tokens: 100_000,
};
assert!(close(price.cost(usage), 2.0));
assert!(close(tokens(u64::from(u32::MAX) + 1), 4_294_967_296.0));
}
}