model-radar/src/catalog.rs

363 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::Error;
const ENDPOINT: &str = "https://openrouter.ai/api/v1/models";
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Model {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub context_length: u64,
#[serde(default, deserialize_with = "deserialize_pricing")]
pub pricing: BTreeMap<String, String>,
}
fn deserialize_pricing<'de, D>(deserializer: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
raw.into_iter()
.map(|(key, value)| {
if key == "overrides" && value.is_array() {
let empty = value.as_array().is_some_and(Vec::is_empty);
return Ok((
key,
if empty {
"0".into()
} else {
"tiered pricing".into()
},
));
}
let value = value.as_str().ok_or_else(|| {
serde::de::Error::custom("expected a pricing string or overrides array")
})?;
Ok((key, value.to_owned()))
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Event {
pub model: Model,
pub kind: String,
}
fn is_decimal_zero(raw: &str) -> bool {
let body = raw.strip_prefix(['+', '-']).unwrap_or(raw);
let mantissa = if let Some((mantissa, exponent)) = body.split_once(['e', 'E']) {
let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
if exponent.is_empty() || !exponent.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
mantissa
} else {
body
};
let mut seen_dot = false;
let mut digits = 0usize;
mantissa.bytes().all(|b| match b {
b'.' if !seen_dot => {
seen_dot = true;
true
}
b'0' => {
digits += 1;
true
}
_ => false,
}) && digits > 0
}
impl Model {
pub fn is_free(&self) -> bool {
if !is_decimal_zero(
self.pricing
.get("prompt")
.map(String::as_str)
.unwrap_or_default(),
) || !is_decimal_zero(
self.pricing
.get("completion")
.map(String::as_str)
.unwrap_or_default(),
) {
return false;
}
self.pricing.values().all(|value| is_decimal_zero(value))
}
}
pub(crate) fn validate(catalog: &BTreeMap<String, Model>) -> Result<(), Error> {
if catalog.is_empty() {
return Err("catalog is empty".into());
}
for (key, model) in catalog {
if model.id.trim().is_empty() || model.name.trim().is_empty() {
return Err("model identity is empty".into());
}
if key != &model.id {
return Err(
format!("catalog key {key:?} does not match model id {:?}", model.id).into(),
);
}
}
Ok(())
}
fn parse_catalog(bytes: &[u8]) -> Result<BTreeMap<String, Model>, Error> {
#[derive(Deserialize)]
struct Response {
data: Vec<Model>,
}
let response: Response = serde_json::from_slice(bytes)?;
let mut catalog = BTreeMap::new();
for model in response.data {
if model.id.trim().is_empty() || model.name.trim().is_empty() {
return Err("model identity is empty".into());
}
if catalog.insert(model.id.clone(), model).is_some() {
return Err("duplicate model id".into());
}
}
validate(&catalog)?;
Ok(catalog)
}
pub async fn fetch(client: &reqwest::Client) -> Result<BTreeMap<String, Model>, Error> {
let mut response = client.get(ENDPOINT).send().await?.error_for_status()?;
let mut payload: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await? {
if chunk.len() > MAX_RESPONSE_BYTES - payload.len() {
return Err("response exceeds 8 MiB limit".into());
}
payload.extend_from_slice(&chunk);
}
parse_catalog(&payload)
}
pub fn changes(
previous: &BTreeMap<String, Model>,
current: &BTreeMap<String, Model>,
) -> Vec<Event> {
let mut events = Vec::new();
for (id, model) in current {
match previous.get(id) {
None => events.push(Event {
model: model.clone(),
kind: "new".to_string(),
}),
Some(old) if !old.is_free() && model.is_free() => events.push(Event {
model: model.clone(),
kind: "paid_to_free".to_string(),
}),
_ => {}
}
}
events
}
#[cfg(test)]
mod tests {
use super::*;
fn model(id: &str, pricing: &[(&str, &str)]) -> Model {
Model {
id: id.to_string(),
name: id.to_string(),
description: String::new(),
context_length: 4096,
pricing: pricing
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
fn map(models: Vec<Model>) -> BTreeMap<String, Model> {
models.into_iter().map(|m| (m.id.clone(), m)).collect()
}
#[test]
fn free_requires_exact_decimal_zero() {
assert!(model("a", &[("prompt", "0"), ("completion", "0")]).is_free());
assert!(model("a", &[("prompt", "0.0"), ("completion", "-0")]).is_free());
assert!(model("a", &[("prompt", "+0.00000"), ("completion", "0")]).is_free());
assert!(
!model(
"a",
&[("prompt", "0.00000000000000000001"), ("completion", "0")]
)
.is_free()
);
assert!(!model("a", &[("prompt", "1e-9"), ("completion", "0")]).is_free());
assert!(!model("a", &[("prompt", ""), ("completion", "0")]).is_free());
assert!(!model("a", &[("prompt", "abc"), ("completion", "0")]).is_free());
assert!(
!model(
"a",
&[("prompt", "0"), ("completion", "0"), ("request", "0.1")]
)
.is_free()
);
assert!(!model("a", &[]).is_free());
}
#[test]
fn decimal_zero_edge_cases() {
for value in ["0", "00", "-0", "+0.00", "0e-9999", "0E+9999", ".0", "0."] {
assert!(
model("a", &[("prompt", value), ("completion", value)]).is_free(),
"{value}"
);
}
for value in [
"1e-9999", "-1e-9999", "NaN", "inf", "-1", ".", "+", "-", "", "0e", "0e+", "0e1.0",
"0e0e0", "0..0", "--0", "0x0", " 0", "0 ", "0",
] {
assert!(
!model("a", &[("prompt", value), ("completion", "0")]).is_free(),
"{value}"
);
assert!(
!model("a", &[("prompt", "0"), ("completion", value)]).is_free(),
"{value}"
);
assert!(
!model(
"a",
&[("prompt", "0"), ("completion", "0"), ("unknown", value)]
)
.is_free(),
"{value}"
);
}
let tiny = format!("0.{}1", "0".repeat(400));
assert!(!model("a", &[("prompt", &tiny), ("completion", "0")]).is_free());
assert!(!model("a", &[("prompt", "0")]).is_free());
assert!(!model("a", &[("completion", "0")]).is_free());
assert!(
model(
"a",
&[("prompt", "0"), ("completion", "0"), ("unknown", "0.0")]
)
.is_free()
);
}
#[test]
fn changes_detects_new_and_paid_to_free() {
let prev = map(vec![
model("a", &[("prompt", "1"), ("completion", "1")]),
model("b", &[("prompt", "0"), ("completion", "0")]),
]);
let cur = map(vec![
model("a", &[("prompt", "0"), ("completion", "0")]),
model("b", &[("prompt", "0"), ("completion", "0")]),
model("c", &[("prompt", "0"), ("completion", "0")]),
model("d", &[("prompt", "2"), ("completion", "3")]),
]);
let events = changes(&prev, &cur);
let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
assert_eq!(kinds, vec!["paid_to_free", "new", "new"]);
assert_eq!(events[0].model.id, "a");
assert_eq!(events[1].model.id, "c");
assert_eq!(events[2].model.id, "d");
assert!(!events[2].model.is_free());
}
#[test]
fn changes_ignores_removals_and_unchanged() {
let prev = map(vec![model("a", &[("prompt", "0"), ("completion", "0")])]);
let cur = BTreeMap::new();
assert!(changes(&prev, &cur).is_empty());
assert_eq!(changes(&cur, &prev).len(), 1);
assert!(changes(&prev, &prev).is_empty());
let paid = map(vec![model("a", &[("prompt", "1"), ("completion", "1")])]);
assert!(changes(&prev, &paid).is_empty());
assert!(changes(&paid, &paid).is_empty());
}
#[test]
fn parse_rejects_empty() {
assert!(parse_catalog(b"{\"data\":[]}").is_err());
assert!(parse_catalog(b"{}").is_err());
}
#[test]
fn parse_rejects_malformed() {
for body in [
"not json",
r#"{"data":null}"#,
r#"{"data":{}}"#,
r#"{"data":[{"id":"","name":"A"}]}"#,
r#"{"data":[{"id":" ","name":"A"}]}"#,
r#"{"data":[{"id":"a","name":" "}]}"#,
r#"{"data":[{"name":"A"}]}"#,
r#"{"data":[{"id":"a"}]}"#,
r#"{"data":[{"id":1,"name":"A"}]}"#,
r#"{"data":[{"id":"a","name":"A","context_length":-1}]}"#,
r#"{"data":[{"id":"a","name":"A","pricing":{"prompt":0}}]}"#,
r#"{"data":[{"id":"a","name":"A","description":null}]}"#,
r#"{"data":[{"id":"a","name":"A"}]} trailing"#,
] {
assert!(parse_catalog(body.as_bytes()).is_err(), "{body}");
}
}
#[test]
fn tiered_pricing_is_accepted_but_not_assumed_free() {
let body = br#"{"data":[{"id":"a","name":"A","pricing":{"prompt":"0","completion":"0","overrides":[{"min_prompt_tokens":100,"prompt":"1"}]}}]}"#;
let catalog = parse_catalog(body).unwrap();
assert!(!catalog["a"].is_free());
let saved = serde_json::to_vec(&catalog["a"]).unwrap();
let restored: Model = serde_json::from_slice(&saved).unwrap();
assert!(!restored.is_free());
let body = br#"{"data":[{"id":"a","name":"A","pricing":{"prompt":"0","completion":"0","overrides":[]}}]}"#;
assert!(parse_catalog(body).unwrap()["a"].is_free());
}
#[test]
fn parse_rejects_duplicates() {
let body = r#"{"data":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}"#;
assert!(parse_catalog(body.as_bytes()).is_err());
}
#[test]
fn parse_accepts_valid() {
let body = r#"{"data":[{"id":"a","name":"A","context_length":8192,"pricing":{"prompt":"0","completion":"0"}}]}"#;
let catalog = parse_catalog(body.as_bytes()).unwrap();
assert_eq!(catalog.len(), 1);
assert!(catalog["a"].is_free());
assert_eq!(catalog["a"].context_length, 8192);
}
#[test]
fn parse_defaults_optional_metadata_and_prices() {
let catalog = parse_catalog(br#"{"data":[{"id":"a","name":"A"}]}"#).unwrap();
assert_eq!(catalog["a"].description, "");
assert_eq!(catalog["a"].context_length, 0);
assert!(catalog["a"].pricing.is_empty());
assert!(!catalog["a"].is_free());
}
#[test]
fn validate_rejects_empty_catalog() {
assert!(validate(&BTreeMap::new()).is_err());
}
#[test]
fn validate_requires_keys_to_match_model_ids() {
let model = model("example/model", &[("prompt", "0"), ("completion", "0")]);
assert!(validate(&BTreeMap::from([("wrong/key".into(), model.clone())])).is_err());
assert!(validate(&BTreeMap::from([(model.id.clone(), model)])).is_ok());
}
}