use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::ProviderKind;
use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogSource, bundled_catalog_offerings};
use crate::models_dev::ModelsDevModalities;
use crate::pricing::{Currency, OfferingPricing};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Modality {
Text,
Multimodal,
#[default]
Unknown,
}
impl Modality {
#[must_use]
pub fn from_modalities(modalities: Option<&ModelsDevModalities>) -> Self {
let Some(modalities) = modalities else {
return Self::Unknown;
};
let mut saw_any = false;
for modality in modalities.input.iter().chain(modalities.output.iter()) {
let trimmed = modality.trim();
if trimmed.is_empty() {
continue;
}
saw_any = true;
if !trimmed.eq_ignore_ascii_case("text") {
return Self::Multimodal;
}
}
if saw_any { Self::Text } else { Self::Unknown }
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Multimodal => "multimodal",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelReferenceCard {
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_kind: Option<ProviderKind>,
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output: Option<u64>,
pub modality: Modality,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<OfferingPricing>,
pub source: CatalogSource,
}
impl ModelReferenceCard {
#[must_use]
pub fn from_offering(offering: &CatalogOffering) -> Self {
Self {
provider: offering.provider.clone(),
provider_kind: ProviderKind::parse(&offering.provider),
model_id: offering.wire_model_id.clone(),
canonical_model: offering.canonical_model.clone(),
family: offering.family.clone(),
context_window: offering.limit.as_ref().and_then(|limit| limit.context),
max_output: offering.limit.as_ref().and_then(|limit| limit.output),
modality: Modality::from_modalities(offering.modalities.as_ref()),
pricing: OfferingPricing::from_catalog_offering(offering),
source: offering.source.clone(),
}
}
#[must_use]
pub fn provider_kind_label(&self) -> &'static str {
self.provider_kind.map_or("unknown", ProviderKind::as_str)
}
#[must_use]
pub fn context_window_label(&self) -> String {
humanize_tokens(self.context_window)
}
#[must_use]
pub fn max_output_label(&self) -> String {
humanize_tokens(self.max_output)
}
#[must_use]
pub fn price_label(&self) -> String {
let Some(pricing) = self.pricing.as_ref() else {
return "unknown".to_string();
};
if pricing.input_per_million.is_none() && pricing.output_per_million.is_none() {
return "unknown".to_string();
}
let symbol = currency_symbol(&pricing.currency);
let render = |value: Option<f64>| match value {
Some(rate) => format!("{symbol}{rate:.2}"),
None => "?".to_string(),
};
let suffix = currency_suffix(&pricing.currency);
format!(
"{} / {} per Mtok{suffix}",
render(pricing.input_per_million),
render(pricing.output_per_million),
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelReferenceDatabase {
cards: Vec<ModelReferenceCard>,
}
impl ModelReferenceDatabase {
#[must_use]
pub fn from_offerings(offerings: &[CatalogOffering]) -> Self {
let mut by_identity: BTreeMap<(String, String), ModelReferenceCard> = BTreeMap::new();
for offering in offerings {
let card = ModelReferenceCard::from_offering(offering);
by_identity.insert((card.provider.clone(), card.model_id.clone()), card);
}
Self {
cards: by_identity.into_values().collect(),
}
}
#[must_use]
pub fn from_snapshot(snapshot: &CatalogSnapshot) -> Self {
Self::from_offerings(&snapshot.offerings)
}
#[must_use]
pub fn bundled() -> Self {
Self::from_offerings(&bundled_catalog_offerings())
}
#[must_use]
pub fn cards(&self) -> &[ModelReferenceCard] {
&self.cards
}
#[must_use]
pub fn len(&self) -> usize {
self.cards.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
#[must_use]
pub fn providers(&self) -> Vec<&str> {
self.cards
.iter()
.map(|card| card.provider.as_str())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[must_use]
pub fn for_provider(&self, provider: &str) -> Vec<&ModelReferenceCard> {
self.cards
.iter()
.filter(|card| card.provider == provider)
.collect()
}
#[must_use]
pub fn find(&self, provider: &str, model_id: &str) -> Option<&ModelReferenceCard> {
self.cards
.iter()
.find(|card| card.provider == provider && card.model_id == model_id)
}
}
fn humanize_tokens(tokens: Option<u64>) -> String {
let Some(tokens) = tokens else {
return "unknown".to_string();
};
if tokens >= 1_000_000 {
let millions = tokens as f64 / 1_000_000.0;
let rendered = format!("{millions:.2}");
let trimmed = rendered.trim_end_matches('0').trim_end_matches('.');
format!("{trimmed}M")
} else if tokens >= 1_000 {
format!("{}K", (tokens as f64 / 1_000.0).round() as u64)
} else {
tokens.to_string()
}
}
fn currency_symbol(currency: &Currency) -> &'static str {
match currency {
Currency::Usd => "$",
Currency::Cny => "¥",
Currency::Other(_) => "",
}
}
fn currency_suffix(currency: &Currency) -> String {
match currency {
Currency::Usd | Currency::Cny => String::new(),
Currency::Other(code) => format!(" {code}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models_dev::{ModelsDevCost, ModelsDevLimit};
fn offering(provider: &str, wire: &str) -> CatalogOffering {
CatalogOffering {
provider: provider.to_string(),
wire_model_id: wire.to_string(),
endpoint_key: "chat".to_string(),
source: CatalogSource::Bundled,
..Default::default()
}
}
#[test]
fn modality_text_multimodal_and_unknown() {
assert_eq!(Modality::from_modalities(None), Modality::Unknown);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities::default())),
Modality::Unknown,
"an empty modality block is unknown, not text-only"
);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["text".to_string()],
})),
Modality::Text
);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities {
input: vec!["text".to_string(), "image".to_string()],
output: vec!["text".to_string()],
})),
Modality::Multimodal
);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities {
input: vec!["TEXT".to_string()],
output: vec!["Audio".to_string()],
})),
Modality::Multimodal
);
}
#[test]
fn card_projects_stated_facts() {
let row = CatalogOffering {
family: Some("deepseek".to_string()),
limit: Some(ModelsDevLimit {
context: Some(1_000_000),
input: None,
output: Some(384_000),
}),
cost: Some(ModelsDevCost {
input: Some(0.3),
output: Some(1.2),
cache_read: Some(0.06),
cache_write: None,
}),
modalities: Some(ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["text".to_string()],
}),
..offering("deepseek", "deepseek-v4-pro")
};
let card = ModelReferenceCard::from_offering(&row);
assert_eq!(card.provider, "deepseek");
assert_eq!(card.provider_kind, Some(ProviderKind::Deepseek));
assert_eq!(card.provider_kind_label(), "deepseek");
assert_eq!(card.model_id, "deepseek-v4-pro");
assert_eq!(card.family.as_deref(), Some("deepseek"));
assert_eq!(card.context_window, Some(1_000_000));
assert_eq!(card.context_window_label(), "1M");
assert_eq!(card.max_output, Some(384_000));
assert_eq!(card.max_output_label(), "384K");
assert_eq!(card.modality, Modality::Text);
assert_eq!(card.price_label(), "$0.30 / $1.20 per Mtok");
}
#[test]
fn custom_local_row_is_all_unknown_but_keeps_model_id_verbatim() {
let row = CatalogOffering {
source: CatalogSource::UserOverride,
..offering("my-local-llm", "Vendor/Custom-Model_v1")
};
let card = ModelReferenceCard::from_offering(&row);
assert_eq!(card.provider_kind, None);
assert_eq!(card.provider_kind_label(), "unknown");
assert_eq!(card.model_id, "Vendor/Custom-Model_v1");
assert_eq!(card.context_window, None);
assert_eq!(card.context_window_label(), "unknown");
assert_eq!(card.max_output_label(), "unknown");
assert_eq!(card.modality, Modality::Unknown);
assert_eq!(card.price_label(), "unknown");
}
#[test]
fn unpriced_and_cache_only_rows_report_unknown_price_never_zero() {
let unpriced = ModelReferenceCard::from_offering(&offering("deepseek", "deepseek-v4-pro"));
assert_eq!(unpriced.price_label(), "unknown");
assert!(unpriced.pricing.is_none());
let cache_only = CatalogOffering {
cost: Some(ModelsDevCost {
input: None,
output: None,
cache_read: Some(0.05),
cache_write: None,
}),
..offering("acme", "house-model")
};
assert_eq!(
ModelReferenceCard::from_offering(&cache_only).price_label(),
"unknown"
);
}
#[test]
fn partial_price_renders_known_rate_and_marks_the_other_unknown() {
let row = CatalogOffering {
cost: Some(ModelsDevCost {
input: Some(5.0),
output: None,
cache_read: None,
cache_write: None,
}),
..offering("openai", "gpt-5.5")
};
assert_eq!(
ModelReferenceCard::from_offering(&row).price_label(),
"$5.00 / ? per Mtok"
);
}
#[test]
fn database_is_sorted_deduped_and_queryable() {
let rows = vec![
CatalogOffering {
limit: Some(ModelsDevLimit {
context: Some(1),
input: None,
output: None,
}),
..offering("zai", "GLM-5.2")
},
offering("deepseek", "deepseek-v4-pro"),
CatalogOffering {
limit: Some(ModelsDevLimit {
context: Some(1_000_000),
input: None,
output: None,
}),
..offering("zai", "GLM-5.2")
},
];
let db = ModelReferenceDatabase::from_offerings(&rows);
assert_eq!(db.len(), 2, "duplicate (provider, model) collapses to one");
assert_eq!(db.cards()[0].provider, "deepseek");
assert_eq!(db.cards()[1].provider, "zai");
assert_eq!(db.providers(), vec!["deepseek", "zai"]);
assert_eq!(db.for_provider("zai").len(), 1);
assert_eq!(
db.find("zai", "GLM-5.2")
.and_then(|card| card.context_window),
Some(1_000_000),
"last-write-wins kept the richer row"
);
assert!(db.find("zai", "missing").is_none());
}
#[test]
fn bundled_database_is_nonempty_and_honest() {
let db = ModelReferenceDatabase::bundled();
assert!(!db.is_empty());
assert!(
db.len() >= 20,
"bundled snapshot should carry the curated offerings, got {}",
db.len()
);
for card in db.cards() {
assert!(!card.model_id.is_empty());
assert!(
card.provider_kind.is_some(),
"bundled provider {} should map to a known kind",
card.provider
);
}
let deepseek = db
.find("deepseek", "deepseek-v4-pro")
.expect("bundled deepseek row");
assert_eq!(deepseek.context_window, Some(1_000_000));
assert_eq!(deepseek.modality, Modality::Text);
assert_eq!(deepseek.price_label(), "unknown");
let minimax = db
.find("minimax", "MiniMax-M3")
.expect("bundled minimax row");
assert_eq!(minimax.price_label(), "$0.30 / $1.20 per Mtok");
}
#[test]
fn humanize_tokens_shapes() {
assert_eq!(humanize_tokens(None), "unknown");
assert_eq!(humanize_tokens(Some(512)), "512");
assert_eq!(humanize_tokens(Some(131_072)), "131K");
assert_eq!(humanize_tokens(Some(1_000_000)), "1M");
assert_eq!(humanize_tokens(Some(1_050_000)), "1.05M");
}
}