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]
221 pub fn bundled() -> Self {
222 Self::from_offerings(&bundled_catalog_offerings())
223 }
224
225 #[must_use]
227 pub fn cards(&self) -> &[ModelReferenceCard] {
228 &self.cards
229 }
230
231 #[must_use]
233 pub fn len(&self) -> usize {
234 self.cards.len()
235 }
236
237 #[must_use]
239 pub fn is_empty(&self) -> bool {
240 self.cards.is_empty()
241 }
242
243 #[must_use]
245 pub fn providers(&self) -> Vec<&str> {
246 self.cards
247 .iter()
248 .map(|card| card.provider.as_str())
249 .collect::<BTreeSet<_>>()
250 .into_iter()
251 .collect()
252 }
253
254 #[must_use]
256 pub fn for_provider(&self, provider: &str) -> Vec<&ModelReferenceCard> {
257 self.cards
258 .iter()
259 .filter(|card| card.provider == provider)
260 .collect()
261 }
262
263 #[must_use]
265 pub fn find(&self, provider: &str, model_id: &str) -> Option<&ModelReferenceCard> {
266 self.cards
267 .iter()
268 .find(|card| card.provider == provider && card.model_id == model_id)
269 }
270}
271
272fn humanize_tokens(tokens: Option<u64>) -> String {
276 let Some(tokens) = tokens else {
277 return "unknown".to_string();
278 };
279 if tokens >= 1_000_000 {
280 let millions = tokens as f64 / 1_000_000.0;
281 let rendered = format!("{millions:.2}");
282 let trimmed = rendered.trim_end_matches('0').trim_end_matches('.');
283 format!("{trimmed}M")
284 } else if tokens >= 1_000 {
285 format!("{}K", (tokens as f64 / 1_000.0).round() as u64)
286 } else {
287 tokens.to_string()
288 }
289}
290
291fn currency_symbol(currency: &Currency) -> &'static str {
292 match currency {
293 Currency::Usd => "$",
294 Currency::Cny => "¥",
295 Currency::Other(_) => "",
296 }
297}
298
299fn currency_suffix(currency: &Currency) -> String {
300 match currency {
301 Currency::Usd | Currency::Cny => String::new(),
302 Currency::Other(code) => format!(" {code}"),
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::models_dev::{ModelsDevCost, ModelsDevLimit};
310
311 fn offering(provider: &str, wire: &str) -> CatalogOffering {
312 CatalogOffering {
313 provider: provider.to_string(),
314 wire_model_id: wire.to_string(),
315 endpoint_key: "chat".to_string(),
316 source: CatalogSource::Bundled,
317 ..Default::default()
318 }
319 }
320
321 #[test]
322 fn modality_text_multimodal_and_unknown() {
323 assert_eq!(Modality::from_modalities(None), Modality::Unknown);
324 assert_eq!(
325 Modality::from_modalities(Some(&ModelsDevModalities::default())),
326 Modality::Unknown,
327 "an empty modality block is unknown, not text-only"
328 );
329 assert_eq!(
330 Modality::from_modalities(Some(&ModelsDevModalities {
331 input: vec!["text".to_string()],
332 output: vec!["text".to_string()],
333 })),
334 Modality::Text
335 );
336 assert_eq!(
337 Modality::from_modalities(Some(&ModelsDevModalities {
338 input: vec!["text".to_string(), "image".to_string()],
339 output: vec!["text".to_string()],
340 })),
341 Modality::Multimodal
342 );
343 assert_eq!(
345 Modality::from_modalities(Some(&ModelsDevModalities {
346 input: vec!["TEXT".to_string()],
347 output: vec!["Audio".to_string()],
348 })),
349 Modality::Multimodal
350 );
351 }
352
353 #[test]
354 fn card_projects_stated_facts() {
355 let row = CatalogOffering {
356 family: Some("deepseek".to_string()),
357 limit: Some(ModelsDevLimit {
358 context: Some(1_000_000),
359 input: None,
360 output: Some(384_000),
361 }),
362 cost: Some(ModelsDevCost {
363 input: Some(0.3),
364 output: Some(1.2),
365 cache_read: Some(0.06),
366 cache_write: None,
367 }),
368 modalities: Some(ModelsDevModalities {
369 input: vec!["text".to_string()],
370 output: vec!["text".to_string()],
371 }),
372 ..offering("deepseek", "deepseek-v4-pro")
373 };
374 let card = ModelReferenceCard::from_offering(&row);
375
376 assert_eq!(card.provider, "deepseek");
377 assert_eq!(card.provider_kind, Some(ProviderKind::Deepseek));
378 assert_eq!(card.provider_kind_label(), "deepseek");
379 assert_eq!(card.model_id, "deepseek-v4-pro");
380 assert_eq!(card.family.as_deref(), Some("deepseek"));
381 assert_eq!(card.context_window, Some(1_000_000));
382 assert_eq!(card.context_window_label(), "1M");
383 assert_eq!(card.max_output, Some(384_000));
384 assert_eq!(card.max_output_label(), "384K");
385 assert_eq!(card.modality, Modality::Text);
386 assert_eq!(card.price_label(), "$0.30 / $1.20 per Mtok");
387 }
388
389 #[test]
390 fn custom_local_row_is_all_unknown_but_keeps_model_id_verbatim() {
391 let row = CatalogOffering {
395 source: CatalogSource::UserOverride,
396 ..offering("my-local-llm", "Vendor/Custom-Model_v1")
397 };
398 let card = ModelReferenceCard::from_offering(&row);
399
400 assert_eq!(card.provider_kind, None);
401 assert_eq!(card.provider_kind_label(), "unknown");
402 assert_eq!(card.model_id, "Vendor/Custom-Model_v1");
403 assert_eq!(card.context_window, None);
404 assert_eq!(card.context_window_label(), "unknown");
405 assert_eq!(card.max_output_label(), "unknown");
406 assert_eq!(card.modality, Modality::Unknown);
407 assert_eq!(card.price_label(), "unknown");
408 }
409
410 #[test]
411 fn unpriced_and_cache_only_rows_report_unknown_price_never_zero() {
412 let unpriced = ModelReferenceCard::from_offering(&offering("deepseek", "deepseek-v4-pro"));
414 assert_eq!(unpriced.price_label(), "unknown");
415 assert!(unpriced.pricing.is_none());
416
417 let cache_only = CatalogOffering {
420 cost: Some(ModelsDevCost {
421 input: None,
422 output: None,
423 cache_read: Some(0.05),
424 cache_write: None,
425 }),
426 ..offering("acme", "house-model")
427 };
428 assert_eq!(
429 ModelReferenceCard::from_offering(&cache_only).price_label(),
430 "unknown"
431 );
432 }
433
434 #[test]
435 fn partial_price_renders_known_rate_and_marks_the_other_unknown() {
436 let row = CatalogOffering {
437 cost: Some(ModelsDevCost {
438 input: Some(5.0),
439 output: None,
440 cache_read: None,
441 cache_write: None,
442 }),
443 ..offering("openai", "gpt-5.5")
444 };
445 assert_eq!(
446 ModelReferenceCard::from_offering(&row).price_label(),
447 "$5.00 / ? per Mtok"
448 );
449 }
450
451 #[test]
452 fn database_is_sorted_deduped_and_queryable() {
453 let rows = vec![
454 CatalogOffering {
455 limit: Some(ModelsDevLimit {
456 context: Some(1),
457 input: None,
458 output: None,
459 }),
460 ..offering("zai", "GLM-5.2")
461 },
462 offering("deepseek", "deepseek-v4-pro"),
463 CatalogOffering {
465 limit: Some(ModelsDevLimit {
466 context: Some(1_000_000),
467 input: None,
468 output: None,
469 }),
470 ..offering("zai", "GLM-5.2")
471 },
472 ];
473 let db = ModelReferenceDatabase::from_offerings(&rows);
474
475 assert_eq!(db.len(), 2, "duplicate (provider, model) collapses to one");
476 assert_eq!(db.cards()[0].provider, "deepseek");
478 assert_eq!(db.cards()[1].provider, "zai");
479 assert_eq!(db.providers(), vec!["deepseek", "zai"]);
480 assert_eq!(db.for_provider("zai").len(), 1);
481 assert_eq!(
482 db.find("zai", "GLM-5.2")
483 .and_then(|card| card.context_window),
484 Some(1_000_000),
485 "last-write-wins kept the richer row"
486 );
487 assert!(db.find("zai", "missing").is_none());
488 }
489
490 #[test]
491 fn bundled_database_is_nonempty_and_honest() {
492 let db = ModelReferenceDatabase::bundled();
493 assert!(!db.is_empty());
494 assert!(
495 db.len() >= 20,
496 "bundled offline snapshot should carry seed offerings, got {}",
497 db.len()
498 );
499
500 for card in db.cards() {
503 assert!(!card.model_id.is_empty());
504 assert!(
505 card.provider_kind.is_some(),
506 "bundled provider {} should map to a known kind",
507 card.provider
508 );
509 }
510
511 let deepseek = db
514 .find("deepseek", "deepseek-v4-pro")
515 .expect("bundled deepseek row");
516 assert_eq!(deepseek.context_window, Some(1_000_000));
517 assert_eq!(deepseek.modality, Modality::Text);
518 assert_eq!(deepseek.price_label(), "unknown");
519
520 let minimax = db
522 .find("minimax", "MiniMax-M2.7")
523 .expect("bundled minimax row");
524 assert_eq!(minimax.price_label(), "$0.30 / $1.20 per Mtok");
525
526 let minimax_m3 = db
529 .find("minimax", "MiniMax-M3")
530 .expect("bundled minimax m3 row");
531 assert_eq!(minimax_m3.price_label(), "unknown");
532 }
533
534 #[test]
535 fn humanize_tokens_shapes() {
536 assert_eq!(humanize_tokens(None), "unknown");
537 assert_eq!(humanize_tokens(Some(512)), "512");
538 assert_eq!(humanize_tokens(Some(131_072)), "131K");
539 assert_eq!(humanize_tokens(Some(1_000_000)), "1M");
540 assert_eq!(humanize_tokens(Some(1_050_000)), "1.05M");
541 }
542}