返回 CodeWhale
model_reference.rs
根目录 / crates / config / src / model_reference.rs
1 //! Factual model reference database (#3205, #2300).
2 //!
3 //! A browsable, read-only projection of the compiled catalog into per-offering
4 //! "fact cards": the model id as-is, the serving provider and its kind, the
5 //! context window, the price, and the modality (text vs multimodal). It exists
6 //! to answer "what are this model's stated attributes?", nothing more.
7 //!
8 //! This layer is **labels only**. It performs no selection, routing, tiering,
9 //! or ranking — it never decides which model to use, and it carries no
10 //! `strong`/`balanced`/`fast` or role concept. It is a superset-free view over
11 //! [`crate::catalog::CatalogOffering`] rows.
12 //!
13 //! Honesty rule (shared with #2608 / #3085): an attribute the catalog layer did
14 //! not state is reported as **unknown**, never guessed. A local/custom endpoint
15 //! with no catalog facts yields `Unknown` modality, `None` context window, and
16 //! an unknown price — its model id is still preserved verbatim. Nothing here is
17 //! inferred from a model-id prefix.
18
19 use std::collections::{BTreeMap, BTreeSet};
20
21 use serde::{Deserialize, Serialize};
22
23 use crate::ProviderKind;
24 use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogSource, bundled_catalog_offerings};
25 use crate::models_dev::ModelsDevModalities;
26 use crate::pricing::{Currency, OfferingPricing};
27
28 /// Coarse, factual input/output modality label for a model.
29 ///
30 /// `text` vs `multimodal` is derived from the union of stated input/output
31 /// modalities. Absent modality metadata is [`Modality::Unknown`], distinct from
32 /// a stated text-only model — "we were not told" is not "text only".
33 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34 #[serde(rename_all = "snake_case")]
35 pub enum Modality {
36 /// Every stated modality is text.
37 Text,
38 /// At least one stated modality is non-text (image/audio/video/…).
39 Multimodal,
40 /// No modality metadata was stated for this row.
41 #[default]
42 Unknown,
43 }
44
45 impl Modality {
46 /// Classify the modality from a Models.dev-shaped modality block.
47 ///
48 /// Returns [`Modality::Unknown`] for absent metadata or an empty list,
49 /// [`Modality::Multimodal`] when any stated input/output modality is not
50 /// `text`, and [`Modality::Text`] when the only stated modalities are text.
51 #[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 /// Stable lowercase label.
71 #[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 /// A factual reference card for one provider offering.
82 ///
83 /// Every field is either a stated fact or an explicit unknown. This is a
84 /// labels-only projection: it carries no routing, tier, or selection concept.
85 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86 pub struct ModelReferenceCard {
87 /// Provider id serving this offering, exactly as the catalog row states it.
88 pub provider: String,
89 /// Resolved built-in provider kind, when the provider id maps to one.
90 ///
91 /// `None` for an unrecognized / user-named custom provider — an unknown
92 /// kind, not a guess.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub provider_kind: Option<ProviderKind>,
95 /// The provider wire model id, verbatim. Never normalized or prefixed.
96 pub model_id: String,
97 /// Canonical model identity, only when the row carried an explicit join.
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub canonical_model: Option<String>,
100 /// Model family / series, when stated.
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub family: Option<String>,
103 /// Context-window tokens, when stated.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub context_window: Option<u64>,
106 /// Max-output tokens, when stated.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub max_output: Option<u64>,
109 /// Text vs multimodal, or unknown.
110 pub modality: Modality,
111 /// Per-token pricing facts, when priced. `None` is unknown, never free.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub pricing: Option<OfferingPricing>,
114 /// Provenance of the underlying catalog row (bundled / live / override).
115 pub source: CatalogSource,
116 }
117
118 impl ModelReferenceCard {
119 /// Project a catalog offering into its factual reference card.
120 #[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 /// Label for the resolved provider kind, or `"unknown"`.
137 #[must_use]
138 pub fn provider_kind_label(&self) -> &'static str {
139 self.provider_kind.map_or("unknown", ProviderKind::as_str)
140 }
141
142 /// Human context-window label such as `"1M"`, `"131K"`, `"512"`, or
143 /// `"unknown"`. The exact token count remains on [`Self::context_window`].
144 #[must_use]
145 pub fn context_window_label(&self) -> String {
146 humanize_tokens(self.context_window)
147 }
148
149 /// Human max-output label, same shape as [`Self::context_window_label`].
150 #[must_use]
151 pub fn max_output_label(&self) -> String {
152 humanize_tokens(self.max_output)
153 }
154
155 /// Short factual price label, e.g. `"$0.30 / $1.20 per Mtok"`, or
156 /// `"unknown"` when no per-token input/output rate is sourced.
157 ///
158 /// A `?` in one slot means that single rate is unknown while the other is
159 /// stated; a fully unknown price collapses to `"unknown"` rather than a
160 /// fabricated zero.
161 #[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 /// A browsable, read-only factual reference database of model offerings.
184 ///
185 /// Cards are sorted by `(provider, model id)` and de-duplicated on that
186 /// identity, so the database is deterministic regardless of input order.
187 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
188 pub struct ModelReferenceDatabase {
189 cards: Vec<ModelReferenceCard>,
190 }
191
192 impl ModelReferenceDatabase {
193 /// Build from raw catalog offerings.
194 ///
195 /// Rows are keyed by `(provider, model id)`; a later row with the same
196 /// identity replaces an earlier one, matching catalog merge semantics.
197 #[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 /// Build from a compiled catalog snapshot (bundled < live < overrides).
210 #[must_use]
211 pub fn from_snapshot(snapshot: &CatalogSnapshot) -> Self {
212 Self::from_offerings(&snapshot.offerings)
213 }
214
215 /// Build from CodeWhale's offline/stale bundled catalog snapshot (#4188).
216 ///
217 /// Prefer a live/compiled [`CatalogSnapshot`] when available. The bundled
218 /// set needs no credentials or network and remains the offline fallback
219 /// every install carries.
220 #[must_use]
221 pub fn bundled() -> Self {
222 Self::from_offerings(&bundled_catalog_offerings())
223 }
224
225 /// All cards, in stable `(provider, model id)` order.
226 #[must_use]
227 pub fn cards(&self) -> &[ModelReferenceCard] {
228 &self.cards
229 }
230
231 /// Number of cards.
232 #[must_use]
233 pub fn len(&self) -> usize {
234 self.cards.len()
235 }
236
237 /// Whether the database is empty.
238 #[must_use]
239 pub fn is_empty(&self) -> bool {
240 self.cards.is_empty()
241 }
242
243 /// Distinct provider ids present, sorted.
244 #[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 /// All cards served by one provider id.
255 #[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 /// Find a card by `(provider, model id)`.
264 #[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
272 /// Round a token count to a short human label (`"1M"`, `"203K"`, `"512"`), or
273 /// `"unknown"` for an absent count. Used for display only; callers needing the
274 /// exact value read the `Option<u64>` field directly.
275 fn 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
291 fn currency_symbol(currency: &Currency) -> &'static str {
292 match currency {
293 Currency::Usd => "$",
294 Currency::Cny => "¥",
295 Currency::Other(_) => "",
296 }
297 }
298
299 fn 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)]
307 mod 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 // Case-insensitive and tolerant of an output-only non-text modality.
344 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 // A user-named custom endpoint with no catalog facts: provider kind,
392 // context window, modality, and price are all unknown — never guessed —
393 // and the model id is preserved exactly.
394 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 // No cost block at all.
413 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 // A cost object priced only on cache classes is still unknown for the
418 // headline input/output rate label.
419 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 // Duplicate identity with a higher context wins (last-write).
464 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 // Sorted by (provider, model id): deepseek before zai.
477 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 // Every card preserves a non-empty model id and resolves a known kind
501 // for the bundled (first-class) providers.
502 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 // A DeepSeek-native row: context window known, price honestly unknown
512 // (the bundled snapshot omits DeepSeek-native per-token pricing).
513 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 // A priced row surfaces its stated per-token rate.
521 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 // MiniMax-M3 ships without verified per-token pricing, so its price
527 // stays honestly unknown rather than inheriting a sibling's rate.
528 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 }
543
543 lines RUST