1use std::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Serialize};
22
23use crate::ProviderKind;
24use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogSource, bundled_catalog_offerings};
25use crate::models_dev::ModelsDevModalities;
26use crate::pricing::{Currency, OfferingPricing};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum Modality {
36 Text,
38 Multimodal,
40 #[default]
42 Unknown,
43}
44
45impl Modality {
46 #[must_use]
52 pub fn from_modalities(modalities: Option<&ModelsDevModalities>) -> Self {
53 let Some(modalities) = modalities else {
54 return Self::Unknown;
55 };
56 let mut saw_any = false;
57 for modality in modalities.input.iter().chain(modalities.output.iter()) {
58 let trimmed = modality.trim();
59 if trimmed.is_empty() {
60 continue;
61 }
62 saw_any = true;
63 if !trimmed.eq_ignore_ascii_case("text") {
64 return Self::Multimodal;
65 }
66 }
67 if saw_any { Self::Text } else { Self::Unknown }
68 }
69
70 #[must_use]
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Text => "text",
75 Self::Multimodal => "multimodal",
76 Self::Unknown => "unknown",
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct ModelReferenceCard {
87 pub provider: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub provider_kind: Option<ProviderKind>,
95 pub model_id: String,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub canonical_model: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub family: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub context_window: Option<u64>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub max_output: Option<u64>,
109 pub modality: Modality,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub pricing: Option<OfferingPricing>,
114 pub source: CatalogSource,
116}
117
118impl ModelReferenceCard {
119 #[must_use]
121 pub fn from_offering(offering: &CatalogOffering) -> Self {
122 Self {
123 provider: offering.provider.clone(),
124 provider_kind: ProviderKind::parse(&offering.provider),
125 model_id: offering.wire_model_id.clone(),
126 canonical_model: offering.canonical_model.clone(),
127 family: offering.family.clone(),
128 context_window: offering.limit.as_ref().and_then(|limit| limit.context),
129 max_output: offering.limit.as_ref().and_then(|limit| limit.output),
130 modality: Modality::from_modalities(offering.modalities.as_ref()),
131 pricing: OfferingPricing::from_catalog_offering(offering),
132 source: offering.source.clone(),
133 }
134 }
135
136 #[must_use]
138 pub fn provider_kind_label(&self) -> &'static str {
139 self.provider_kind.map_or("unknown", ProviderKind::as_str)
140 }
141
142 #[must_use]
145 pub fn context_window_label(&self) -> String {
146 humanize_tokens(self.context_window)
147 }
148
149 #[must_use]
151 pub fn max_output_label(&self) -> String {
152 humanize_tokens(self.max_output)
153 }
154
155 #[must_use]
162 pub fn price_label(&self) -> String {
163 let Some(pricing) = self.pricing.as_ref() else {
164 return "unknown".to_string();
165 };
166 if pricing.input_per_million.is_none() && pricing.output_per_million.is_none() {
167 return "unknown".to_string();
168 }
169 let symbol = currency_symbol(&pricing.currency);
170 let render = |value: Option<f64>| match value {
171 Some(rate) => format!("{symbol}{rate:.2}"),
172 None => "?".to_string(),
173 };
174 let suffix = currency_suffix(&pricing.currency);
175 format!(
176 "{} / {} per Mtok{suffix}",
177 render(pricing.input_per_million),
178 render(pricing.output_per_million),
179 )
180 }
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
188pub struct ModelReferenceDatabase {
189 cards: Vec<ModelReferenceCard>,
190}
191
192impl ModelReferenceDatabase {
193 #[must_use]
198 pub fn from_offerings(offerings: &[CatalogOffering]) -> Self {
199 let mut by_identity: BTreeMap<(String, String), ModelReferenceCard> = BTreeMap::new();
200 for offering in offerings {
201 let card = ModelReferenceCard::from_offering(offering);
202 by_identity.insert((card.provider.clone(), card.model_id.clone()), card);
203 }
204 Self {
205 cards: by_identity.into_values().collect(),
206 }
207 }
208
209 #[must_use]
211 pub fn from_snapshot(snapshot: &CatalogSnapshot) -> Self {
212 Self::from_offerings(&snapshot.offerings)
213 }
214
215 #[must_use]
220 pub fn bundled() -> Self {
221 Self::from_offerings(&bundled_catalog_offerings())
222 }
223
224 #[must_use]
226 pub fn cards(&self) -> &[ModelReferenceCard] {
227 &self.cards
228 }
229
230 #[must_use]
232 pub fn len(&self) -> usize {
233 self.cards.len()
234 }
235
236 #[must_use]
238 pub fn is_empty(&self) -> bool {
239 self.cards.is_empty()
240 }
241
242 #[must_use]
244 pub fn providers(&self) -> Vec<&str> {
245 self.cards
246 .iter()
247 .map(|card| card.provider.as_str())
248 .collect::<BTreeSet<_>>()
249 .into_iter()
250 .collect()
251 }
252
253 #[must_use]
255 pub fn for_provider(&self, provider: &str) -> Vec<&ModelReferenceCard> {
256 self.cards
257 .iter()
258 .filter(|card| card.provider == provider)
259 .collect()
260 }
261
262 #[must_use]
264 pub fn find(&self, provider: &str, model_id: &str) -> Option<&ModelReferenceCard> {
265 self.cards
266 .iter()
267 .find(|card| card.provider == provider && card.model_id == model_id)
268 }
269}
270
271fn humanize_tokens(tokens: Option<u64>) -> String {
275 let Some(tokens) = tokens else {
276 return "unknown".to_string();
277 };
278 if tokens >= 1_000_000 {
279 let millions = tokens as f64 / 1_000_000.0;
280 let rendered = format!("{millions:.2}");
281 let trimmed = rendered.trim_end_matches('0').trim_end_matches('.');
282 format!("{trimmed}M")
283 } else if tokens >= 1_000 {
284 format!("{}K", (tokens as f64 / 1_000.0).round() as u64)
285 } else {
286 tokens.to_string()
287 }
288}
289
290fn currency_symbol(currency: &Currency) -> &'static str {
291 match currency {
292 Currency::Usd => "$",
293 Currency::Cny => "¥",
294 Currency::Other(_) => "",
295 }
296}
297
298fn currency_suffix(currency: &Currency) -> String {
299 match currency {
300 Currency::Usd | Currency::Cny => String::new(),
301 Currency::Other(code) => format!(" {code}"),
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::models_dev::{ModelsDevCost, ModelsDevLimit};
309
310 fn offering(provider: &str, wire: &str) -> CatalogOffering {
311 CatalogOffering {
312 provider: provider.to_string(),
313 wire_model_id: wire.to_string(),
314 endpoint_key: "chat".to_string(),
315 source: CatalogSource::Bundled,
316 ..Default::default()
317 }
318 }
319
320 #[test]
321 fn modality_text_multimodal_and_unknown() {
322 assert_eq!(Modality::from_modalities(None), Modality::Unknown);
323 assert_eq!(
324 Modality::from_modalities(Some(&ModelsDevModalities::default())),
325 Modality::Unknown,
326 "an empty modality block is unknown, not text-only"
327 );
328 assert_eq!(
329 Modality::from_modalities(Some(&ModelsDevModalities {
330 input: vec!["text".to_string()],
331 output: vec!["text".to_string()],
332 })),
333 Modality::Text
334 );
335 assert_eq!(
336 Modality::from_modalities(Some(&ModelsDevModalities {
337 input: vec!["text".to_string(), "image".to_string()],
338 output: vec!["text".to_string()],
339 })),
340 Modality::Multimodal
341 );
342 assert_eq!(
344 Modality::from_modalities(Some(&ModelsDevModalities {
345 input: vec!["TEXT".to_string()],
346 output: vec!["Audio".to_string()],
347 })),
348 Modality::Multimodal
349 );
350 }
351
352 #[test]
353 fn card_projects_stated_facts() {
354 let row = CatalogOffering {
355 family: Some("deepseek".to_string()),
356 limit: Some(ModelsDevLimit {
357 context: Some(1_000_000),
358 input: None,
359 output: Some(384_000),
360 }),
361 cost: Some(ModelsDevCost {
362 input: Some(0.3),
363 output: Some(1.2),
364 cache_read: Some(0.06),
365 cache_write: None,
366 }),
367 modalities: Some(ModelsDevModalities {
368 input: vec!["text".to_string()],
369 output: vec!["text".to_string()],
370 }),
371 ..offering("deepseek", "deepseek-v4-pro")
372 };
373 let card = ModelReferenceCard::from_offering(&row);
374
375 assert_eq!(card.provider, "deepseek");
376 assert_eq!(card.provider_kind, Some(ProviderKind::Deepseek));
377 assert_eq!(card.provider_kind_label(), "deepseek");
378 assert_eq!(card.model_id, "deepseek-v4-pro");
379 assert_eq!(card.family.as_deref(), Some("deepseek"));
380 assert_eq!(card.context_window, Some(1_000_000));
381 assert_eq!(card.context_window_label(), "1M");
382 assert_eq!(card.max_output, Some(384_000));
383 assert_eq!(card.max_output_label(), "384K");
384 assert_eq!(card.modality, Modality::Text);
385 assert_eq!(card.price_label(), "$0.30 / $1.20 per Mtok");
386 }
387
388 #[test]
389 fn custom_local_row_is_all_unknown_but_keeps_model_id_verbatim() {
390 let row = CatalogOffering {
394 source: CatalogSource::UserOverride,
395 ..offering("my-local-llm", "Vendor/Custom-Model_v1")
396 };
397 let card = ModelReferenceCard::from_offering(&row);
398
399 assert_eq!(card.provider_kind, None);
400 assert_eq!(card.provider_kind_label(), "unknown");
401 assert_eq!(card.model_id, "Vendor/Custom-Model_v1");
402 assert_eq!(card.context_window, None);
403 assert_eq!(card.context_window_label(), "unknown");
404 assert_eq!(card.max_output_label(), "unknown");
405 assert_eq!(card.modality, Modality::Unknown);
406 assert_eq!(card.price_label(), "unknown");
407 }
408
409 #[test]
410 fn unpriced_and_cache_only_rows_report_unknown_price_never_zero() {
411 let unpriced = ModelReferenceCard::from_offering(&offering("deepseek", "deepseek-v4-pro"));
413 assert_eq!(unpriced.price_label(), "unknown");
414 assert!(unpriced.pricing.is_none());
415
416 let cache_only = CatalogOffering {
419 cost: Some(ModelsDevCost {
420 input: None,
421 output: None,
422 cache_read: Some(0.05),
423 cache_write: None,
424 }),
425 ..offering("acme", "house-model")
426 };
427 assert_eq!(
428 ModelReferenceCard::from_offering(&cache_only).price_label(),
429 "unknown"
430 );
431 }
432
433 #[test]
434 fn partial_price_renders_known_rate_and_marks_the_other_unknown() {
435 let row = CatalogOffering {
436 cost: Some(ModelsDevCost {
437 input: Some(5.0),
438 output: None,
439 cache_read: None,
440 cache_write: None,
441 }),
442 ..offering("openai", "gpt-5.5")
443 };
444 assert_eq!(
445 ModelReferenceCard::from_offering(&row).price_label(),
446 "$5.00 / ? per Mtok"
447 );
448 }
449
450 #[test]
451 fn database_is_sorted_deduped_and_queryable() {
452 let rows = vec![
453 CatalogOffering {
454 limit: Some(ModelsDevLimit {
455 context: Some(1),
456 input: None,
457 output: None,
458 }),
459 ..offering("zai", "GLM-5.2")
460 },
461 offering("deepseek", "deepseek-v4-pro"),
462 CatalogOffering {
464 limit: Some(ModelsDevLimit {
465 context: Some(1_000_000),
466 input: None,
467 output: None,
468 }),
469 ..offering("zai", "GLM-5.2")
470 },
471 ];
472 let db = ModelReferenceDatabase::from_offerings(&rows);
473
474 assert_eq!(db.len(), 2, "duplicate (provider, model) collapses to one");
475 assert_eq!(db.cards()[0].provider, "deepseek");
477 assert_eq!(db.cards()[1].provider, "zai");
478 assert_eq!(db.providers(), vec!["deepseek", "zai"]);
479 assert_eq!(db.for_provider("zai").len(), 1);
480 assert_eq!(
481 db.find("zai", "GLM-5.2")
482 .and_then(|card| card.context_window),
483 Some(1_000_000),
484 "last-write-wins kept the richer row"
485 );
486 assert!(db.find("zai", "missing").is_none());
487 }
488
489 #[test]
490 fn bundled_database_is_nonempty_and_honest() {
491 let db = ModelReferenceDatabase::bundled();
492 assert!(!db.is_empty());
493 assert!(
494 db.len() >= 20,
495 "bundled snapshot should carry the curated offerings, got {}",
496 db.len()
497 );
498
499 for card in db.cards() {
502 assert!(!card.model_id.is_empty());
503 assert!(
504 card.provider_kind.is_some(),
505 "bundled provider {} should map to a known kind",
506 card.provider
507 );
508 }
509
510 let deepseek = db
513 .find("deepseek", "deepseek-v4-pro")
514 .expect("bundled deepseek row");
515 assert_eq!(deepseek.context_window, Some(1_000_000));
516 assert_eq!(deepseek.modality, Modality::Text);
517 assert_eq!(deepseek.price_label(), "unknown");
518
519 let minimax = db
521 .find("minimax", "MiniMax-M3")
522 .expect("bundled minimax row");
523 assert_eq!(minimax.price_label(), "$0.30 / $1.20 per Mtok");
524 }
525
526 #[test]
527 fn humanize_tokens_shapes() {
528 assert_eq!(humanize_tokens(None), "unknown");
529 assert_eq!(humanize_tokens(Some(512)), "512");
530 assert_eq!(humanize_tokens(Some(131_072)), "131K");
531 assert_eq!(humanize_tokens(Some(1_000_000)), "1M");
532 assert_eq!(humanize_tokens(Some(1_050_000)), "1.05M");
533 }
534}