From 81449eb3de2a81bc7fbf686fcdfb4d0c0a3e4692 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sat, 18 Jul 2026 14:03:51 +0200 Subject: [PATCH] cortexkit-model-catalog: shared models.dev representation (types + parsing, no data) Two consumers read the catalog for different reasons (broca: capabilities on the serving path; astrocyte: pricing) and must parse the same shape so schema drift cannot make them disagree silently. Money discipline: dollar rates are converted once, at the parse boundary, to exact integer nanodollars per million tokens via decimal string scaling; a rate that cannot scale exactly is a parse error, never a rounded guess; a missing rate is None, never $0. Unmodeled fields are preserved verbatim in raw passthrough. --- Cargo.toml | 2 +- crates/cortexkit-model-catalog/Cargo.toml | 16 + crates/cortexkit-model-catalog/src/lib.rs | 458 ++++++++++++++++++++++ 3 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 crates/cortexkit-model-catalog/Cargo.toml create mode 100644 crates/cortexkit-model-catalog/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index e607185..9ad1880 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core"] +members = ["crates/cortexkit-paths", "crates/cortexkit-lease", "crates/cortexkit-store-types", "crates/cortexkit-store", "crates/cortexkit-store-postgres", "crates/cortexkit-cache-core", "crates/cortexkit-model-catalog"] # cortexkit/commons — neutral home for cross-product CortexKit primitives. # Shared by subc, AFT, and Magic Context. Each crate is published independently diff --git a/crates/cortexkit-model-catalog/Cargo.toml b/crates/cortexkit-model-catalog/Cargo.toml new file mode 100644 index 0000000..e800916 --- /dev/null +++ b/crates/cortexkit-model-catalog/Cargo.toml @@ -0,0 +1,16 @@ +# cortexkit-model-catalog — the shared REPRESENTATION of the models.dev model +# catalog: types + parsing only, NO bundled data. Consumers (broca for +# capabilities, astrocyte for pricing) parse the same shape by construction; +# each brings its own snapshot and owns its own derived stores. +[package] +name = "cortexkit-model-catalog" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Shared parsing + types for the models.dev model catalog (no data): providers, models, capabilities, and exact integer-nanodollar cost rates." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/cortexkit-model-catalog/src/lib.rs b/crates/cortexkit-model-catalog/src/lib.rs new file mode 100644 index 0000000..537f5f6 --- /dev/null +++ b/crates/cortexkit-model-catalog/src/lib.rs @@ -0,0 +1,458 @@ +//! Shared representation of the models.dev model catalog: types + parsing, +//! deliberately NO bundled data. +//! +//! Two CortexKit consumers read the catalog for different reasons — broca for +//! capabilities/limits on the serving path, astrocyte for pricing — and the +//! fleet rule is that they must parse the SAME shape so a catalog schema +//! drift cannot make them disagree silently. This crate is that shape. +//! Consumers bring their own snapshot bytes and own their derived stores. +//! +//! Money discipline: models.dev publishes dollar-per-million-token rates as +//! JSON decimal numbers. This crate converts them ONCE, at the parse +//! boundary, into exact integer NANODOLLARS per million tokens +//! ([`RateNanosPerMtok`]) via decimal string scaling — no float ever reaches +//! a consumer's money path. A rate the decimal cannot represent exactly in +//! nanodollars is a parse error, never a rounded guess. `None` = "no +//! published rate", which is NOT zero (free) — consumers must distinguish +//! them. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Integer nanodollars per million tokens. $3/M tokens = 3_000_000_000. +pub type RateNanosPerMtok = i64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CatalogParseError { + Json(String), + /// A cost number that cannot scale exactly to integer nanodollars + /// (more than 9 fractional digits, out of range, or not a plain decimal). + InexactRate { + provider: String, + model: String, + field: &'static str, + value: String, + }, +} + +impl std::fmt::Display for CatalogParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CatalogParseError::Json(e) => write!(f, "catalog json: {e}"), + CatalogParseError::InexactRate { provider, model, field, value } => write!( + f, + "catalog rate {provider}/{model}.{field} = {value} cannot scale exactly to nanodollars" + ), + } + } +} + +impl std::error::Error for CatalogParseError {} + +/// The parsed catalog: provider id → provider entry. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CatalogDoc { + pub providers: BTreeMap, +} + +/// One provider, with the fields both consumers rely on. Unmodeled catalog +/// fields are preserved verbatim in `raw` (ingestion-only passthrough) so a +/// schema addition never silently drops data. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ProviderEntry { + pub id: String, + pub name: Option, + pub api: Option, + pub npm: Option, + pub models: BTreeMap, + pub raw: Value, +} + +/// One model offered by a provider. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ModelEntry { + /// The wire model id (what goes in a request's `model` field). + pub id: String, + pub display_name: Option, + pub family: Option, + pub capabilities: Capabilities, + pub limits: Limits, + pub cost: CostSchedule, + pub release_date: Option, + pub status: Option, + pub raw: Value, +} + +/// Capability flags (mirrors the catalog's booleans). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Capabilities { + #[serde(default)] + pub attachment: bool, + #[serde(default)] + pub reasoning: bool, + #[serde(default)] + pub temperature: bool, + #[serde(default)] + pub tool_call: bool, +} + +/// Token limits (mirrors the catalog). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Limits { + #[serde(default)] + pub context: Option, + #[serde(default)] + pub max_input: Option, + #[serde(default)] + pub max_output: Option, +} + +/// Per-token-class rates in exact integer nanodollars per million tokens. +/// +/// `None` = the catalog did not state a rate — NOT the same as zero (free). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CostSchedule { + pub input: Option, + pub output: Option, + pub cache_read: Option, + pub cache_write: Option, + pub reasoning: Option, + pub input_audio: Option, + pub output_audio: Option, + /// Context-size pricing tiers, ascending by `min_context`. Empty = flat. + pub tiers: Vec, +} + +/// One context-size pricing tier: rates that apply at/above `min_context`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CostTier { + pub min_context: u64, + pub input: Option, + pub output: Option, + pub cache_read: Option, + pub cache_write: Option, +} + +impl CatalogDoc { + /// Parse a models.dev snapshot (the top-level `{ provider_id: {...} }` + /// document). Unknown fields are preserved in `raw`, never dropped. + pub fn parse(json: &str) -> Result { + let root: Value = + serde_json::from_str(json).map_err(|e| CatalogParseError::Json(e.to_string()))?; + let obj = root + .as_object() + .ok_or_else(|| CatalogParseError::Json("top level is not an object".into()))?; + let mut providers = BTreeMap::new(); + for (provider_id, entry) in obj { + providers.insert(provider_id.clone(), parse_provider(provider_id, entry)?); + } + Ok(Self { providers }) + } + + pub fn model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option<(&ProviderEntry, &ModelEntry)> { + let provider = self.providers.get(provider_id)?; + let model = provider.models.get(model_id)?; + Some((provider, model)) + } +} + +fn parse_provider(id: &str, entry: &Value) -> Result { + let mut models = BTreeMap::new(); + if let Some(model_map) = entry.get("models").and_then(Value::as_object) { + for (model_id, model_entry) in model_map { + models.insert(model_id.clone(), parse_model(id, model_id, model_entry)?); + } + } + Ok(ProviderEntry { + id: id.to_string(), + name: entry.get("name").and_then(Value::as_str).map(String::from), + api: entry.get("api").and_then(Value::as_str).map(String::from), + npm: entry.get("npm").and_then(Value::as_str).map(String::from), + models, + raw: entry.clone(), + }) +} + +fn parse_model(provider: &str, id: &str, entry: &Value) -> Result { + let capabilities = Capabilities { + attachment: flag(entry, "attachment"), + reasoning: flag(entry, "reasoning"), + temperature: flag(entry, "temperature"), + tool_call: flag(entry, "tool_call"), + }; + let limits = entry + .get("limit") + .map(|l| Limits { + context: l.get("context").and_then(Value::as_u64), + max_input: l.get("input").and_then(Value::as_u64), + max_output: l.get("output").and_then(Value::as_u64), + }) + .unwrap_or_default(); + let cost = match entry.get("cost") { + None => CostSchedule::default(), + Some(c) => parse_cost(provider, id, c)?, + }; + Ok(ModelEntry { + id: id.to_string(), + display_name: entry.get("name").and_then(Value::as_str).map(String::from), + family: entry + .get("family") + .and_then(Value::as_str) + .map(String::from), + capabilities, + limits, + cost, + release_date: entry + .get("release_date") + .and_then(Value::as_str) + .map(String::from), + status: entry + .get("status") + .and_then(Value::as_str) + .map(String::from), + raw: entry.clone(), + }) +} + +fn flag(entry: &Value, key: &str) -> bool { + entry.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn parse_cost( + provider: &str, + model: &str, + cost: &Value, +) -> Result { + let rate = |field: &'static str| -> Result, CatalogParseError> { + match cost.get(field) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + dollars_to_nanos(v) + .map(Some) + .map_err(|value| CatalogParseError::InexactRate { + provider: provider.to_string(), + model: model.to_string(), + field, + value, + }) + } + } + }; + let mut tiers = Vec::new(); + if let Some(list) = cost.get("tiers").and_then(Value::as_array) { + for tier in list { + let trate = + |field: &'static str| -> Result, CatalogParseError> { + match tier.get(field) { + None | Some(Value::Null) => Ok(None), + Some(v) => dollars_to_nanos(v).map(Some).map_err(|value| { + CatalogParseError::InexactRate { + provider: provider.to_string(), + model: model.to_string(), + field, + value, + } + }), + } + }; + tiers.push(CostTier { + min_context: tier + .get("context_over") + .or_else(|| tier.get("min_context")) + .and_then(Value::as_u64) + .unwrap_or(0), + input: trate("input")?, + output: trate("output")?, + cache_read: trate("cache_read")?, + cache_write: trate("cache_write")?, + }); + } + tiers.sort_by_key(|t| t.min_context); + } + Ok(CostSchedule { + input: rate("input")?, + output: rate("output")?, + cache_read: rate("cache_read")?, + cache_write: rate("cache_write")?, + reasoning: rate("reasoning")?, + input_audio: rate("input_audio")?, + output_audio: rate("output_audio")?, + tiers, + }) +} + +/// Convert a catalog dollar rate (JSON number) to exact integer nanodollars +/// via DECIMAL STRING scaling — floats never do money arithmetic. +/// +/// The JSON number's shortest-roundtrip decimal form is scaled by 10^9 +/// exactly; more than 9 fractional digits, exponents beyond range, or a +/// non-finite value is an error (returns the offending textual value). +fn dollars_to_nanos(v: &Value) -> Result { + let n = v.as_number().ok_or_else(|| v.to_string())?; + decimal_str_to_nanos(&n.to_string()).ok_or_else(|| n.to_string()) +} + +fn decimal_str_to_nanos(s: &str) -> Option { + // Split off an exponent (serde prints e.g. 1e-7 for tiny rates). + let (mantissa, exp) = match s.find(['e', 'E']) { + Some(idx) => { + let exp: i32 = s[idx + 1..].parse().ok()?; + (&s[..idx], exp) + } + None => (s, 0), + }; + let negative = mantissa.starts_with('-'); + let mantissa = mantissa.trim_start_matches(['-', '+']); + let (int_part, frac_part) = match mantissa.find('.') { + Some(idx) => (&mantissa[..idx], &mantissa[idx + 1..]), + None => (mantissa, ""), + }; + if int_part.is_empty() && frac_part.is_empty() { + return None; + } + if !int_part.chars().all(|c| c.is_ascii_digit()) + || !frac_part.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + // digits = int_part + frac_part, decimal point sits after int_part.len(), + // then shift by exp. Effective fractional digits = frac.len() - exp. + let digits: String = format!("{int_part}{frac_part}"); + let digits_trimmed = digits.trim_start_matches('0'); + let value: i128 = if digits_trimmed.is_empty() { + 0 + } else { + digits_trimmed.parse().ok()? + }; + // value × 10^(exp - frac_len) dollars → nanos = value × 10^(9 + exp - frac_len) + let shift = 9 + exp - frac_part.len() as i32; + if shift < 0 { + return None; // sub-nanodollar precision: cannot represent exactly + } + let scaled = value.checked_mul(10i128.checked_pow(shift as u32)?)?; + let scaled = if negative { -scaled } else { scaled }; + RateNanosPerMtok::try_from(scaled).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_scaling_is_exact() { + assert_eq!(decimal_str_to_nanos("3"), Some(3_000_000_000)); + assert_eq!(decimal_str_to_nanos("3.75"), Some(3_750_000_000)); + assert_eq!(decimal_str_to_nanos("0.3"), Some(300_000_000)); + // The classic float trap: 0.1 + 0.2 style artifacts cannot arise — + // "0.07" scales as digits, not as a binary float. + assert_eq!(decimal_str_to_nanos("0.07"), Some(70_000_000)); + assert_eq!(decimal_str_to_nanos("0"), Some(0)); + // Exponent forms (serde prints tiny rates this way). + assert_eq!(decimal_str_to_nanos("1e-7"), Some(100)); + assert_eq!(decimal_str_to_nanos("2.5e-3"), Some(2_500_000)); + // Sub-nanodollar precision is an ERROR, not a rounded guess. + assert_eq!(decimal_str_to_nanos("1e-10"), None); + assert_eq!(decimal_str_to_nanos("0.0000000001"), None); + } + + fn snapshot() -> &'static str { + r#"{ + "anthropic": { + "name": "Anthropic", + "api": "https://api.anthropic.com", + "models": { + "claude-test-4": { + "name": "Claude Test 4", + "reasoning": true, + "tool_call": true, + "limit": { "context": 200000, "output": 64000 }, + "cost": { + "input": 3, "output": 15, + "cache_read": 0.3, "cache_write": 3.75 + } + }, + "claude-unpriced": { "name": "No cost row" } + } + }, + "somehost": { + "models": { + "tiered": { + "cost": { + "input": 1.25, "output": 10, + "tiers": [ + { "context_over": 200000, "input": 2.5, "output": 15 } + ] + } + } + } + } + }"# + } + + #[test] + fn parses_providers_models_rates() { + let doc = CatalogDoc::parse(snapshot()).unwrap(); + let (provider, model) = doc.model("anthropic", "claude-test-4").unwrap(); + assert_eq!(provider.name.as_deref(), Some("Anthropic")); + assert!(model.capabilities.reasoning); + assert_eq!(model.limits.context, Some(200_000)); + assert_eq!(model.cost.input, Some(3_000_000_000)); + assert_eq!(model.cost.output, Some(15_000_000_000)); + assert_eq!(model.cost.cache_read, Some(300_000_000)); + assert_eq!(model.cost.cache_write, Some(3_750_000_000)); + // No published reasoning rate: None, NOT zero. + assert_eq!(model.cost.reasoning, None); + } + + #[test] + fn missing_cost_block_is_all_none_not_zero() { + let doc = CatalogDoc::parse(snapshot()).unwrap(); + let (_, model) = doc.model("anthropic", "claude-unpriced").unwrap(); + assert_eq!(model.cost, CostSchedule::default()); + assert_eq!(model.cost.input, None, "no rate is None, never $0"); + } + + #[test] + fn tiers_parse_sorted() { + let doc = CatalogDoc::parse(snapshot()).unwrap(); + let (_, model) = doc.model("somehost", "tiered").unwrap(); + assert_eq!(model.cost.tiers.len(), 1); + assert_eq!(model.cost.tiers[0].min_context, 200_000); + assert_eq!(model.cost.tiers[0].input, Some(2_500_000_000)); + } + + #[test] + fn raw_passthrough_preserves_unmodeled_fields() { + let doc = + CatalogDoc::parse(r#"{ "p": { "future_field": {"x": 1}, "models": {} } }"#).unwrap(); + let provider = doc.providers.get("p").unwrap(); + assert_eq!(provider.raw.get("future_field").unwrap()["x"], 1); + } + + #[test] + fn inexact_rate_is_a_loud_error() { + let err = + CatalogDoc::parse(r#"{ "p": { "models": { "m": { "cost": { "input": 1e-10 } } } } }"#) + .unwrap_err(); + match err { + CatalogParseError::InexactRate { + provider, + model, + field, + .. + } => { + assert_eq!( + (provider.as_str(), model.as_str(), field), + ("p", "m", "input") + ); + } + other => panic!("expected InexactRate, got {other:?}"), + } + } +}