1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4
5#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
12pub struct ModelCost {
13 pub input: f64,
15 pub output: f64,
17 #[serde(default)]
19 pub cache_read: Option<f64>,
20 #[serde(default)]
22 pub cache_write: Option<f64>,
23}
24
25#[non_exhaustive]
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
28pub struct ModelLimit {
29 pub context: u64,
31 pub output: u64,
33}
34
35#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
38pub struct ModelModalities {
39 pub input: Vec<String>,
41 pub output: Vec<String>,
43}
44
45#[non_exhaustive]
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
48pub struct ModelCapabilities {
49 #[serde(default)]
51 pub attachment: bool,
52 #[serde(default)]
54 pub reasoning: bool,
55 #[serde(default)]
57 pub temperature: bool,
58 #[serde(default)]
60 pub tool_call: bool,
61 #[serde(default = "default_true")]
63 pub streaming: bool,
64}
65
66fn default_true() -> bool {
67 true
68}
69
70#[non_exhaustive]
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
73pub struct ModelDescriptor {
74 pub id: String,
76 pub name: String,
78 #[serde(default)]
80 pub family: Option<String>,
81 #[serde(default)]
83 pub release_date: Option<String>,
84 #[serde(default)]
86 pub cost: Option<ModelCost>,
87 #[serde(default)]
89 pub limit: Option<ModelLimit>,
90 #[serde(default)]
92 pub modalities: Option<ModelModalities>,
93 #[serde(default)]
95 pub capabilities: Option<ModelCapabilities>,
96 #[serde(default)]
98 pub knowledge: Option<String>,
99}
100
101#[non_exhaustive]
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
108pub struct ServiceDescriptor {
109 pub id: String,
111 #[serde(rename = "display_name")]
113 pub display_name: String,
114 pub description: String,
116 pub category: String,
118 pub family: String,
120 #[serde(rename = "auth_mode")]
122 pub auth_mode: String,
123 #[serde(rename = "key_var", skip_serializing_if = "String::is_empty", default)]
125 pub key_var: String,
126 #[serde(
128 rename = "literal_auth_token",
129 skip_serializing_if = "String::is_empty",
130 default
131 )]
132 pub literal_auth_token: String,
133 #[serde(rename = "base_url")]
135 pub base_url: String,
136 #[serde(rename = "default_model")]
138 pub default_model: String,
139 #[serde(rename = "model_tiers", default)]
141 pub model_tiers: HashMap<String, String>,
142 #[serde(rename = "model_choices", default)]
144 pub model_choices: Vec<ModelChoice>,
145 #[serde(rename = "test_url")]
147 pub test_url: String,
148 #[serde(default)]
150 pub setup: Vec<String>,
151 #[serde(default)]
153 pub usage: Vec<String>,
154
155 #[serde(default)]
158 pub api_base_url: Option<String>,
159 #[serde(default)]
161 pub npm_package: Option<String>,
162 #[serde(default)]
164 pub doc_url: Option<String>,
165 #[serde(default)]
167 pub models: Vec<ModelDescriptor>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub data_policy: Option<crate::provider::policy::DataPolicy>,
172}
173
174#[non_exhaustive]
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
178pub struct ModelChoice {
179 pub id: String,
181 pub description: String,
183}
184
185#[derive(Debug, Serialize, Deserialize)]
186struct IndexPayload {
187 providers: Vec<ServiceDescriptor>,
188}
189
190#[derive(Debug, Clone)]
199pub struct ProviderIndex {
200 entries: Vec<ServiceDescriptor>,
201 index: HashMap<String, usize>,
202}
203
204impl ProviderIndex {
205 fn from_payload(payload: IndexPayload) -> Self {
206 Self::from_providers(payload.providers)
207 }
208
209 pub fn from_providers(providers: Vec<ServiceDescriptor>) -> Self {
217 let index: HashMap<String, usize> = providers
218 .iter()
219 .enumerate()
220 .map(|(i, p)| (p.id.clone(), i))
221 .collect();
222 Self {
223 entries: providers,
224 index,
225 }
226 }
227
228 pub fn embedded() -> &'static ProviderIndex {
230 &EMBEDDED
231 }
232
233 #[cfg(feature = "discovery")]
245 pub fn with_discovered(&self, discovered: &[crate::discovery::ModelEntry]) -> ProviderIndex {
246 let mut entries: Vec<ServiceDescriptor> = self.entries.clone();
247 let mut synthetic: Option<ServiceDescriptor> = None;
248
249 for entry in discovered {
250 let model: ModelDescriptor = entry.clone().into();
251 match self.index.get(&entry.provider_id).copied() {
252 Some(idx) => {
253 let provider = &mut entries[idx];
254 if let Some(pos) = provider.models.iter().position(|m| m.id == model.id) {
255 provider.models[pos] = model;
256 } else {
257 provider.models.push(model);
258 }
259 }
260 None => {
261 let synth = synthetic.get_or_insert_with(|| ServiceDescriptor {
262 id: "discovered".to_string(),
263 display_name: "Discovered".to_string(),
264 description: "Runtime-discovered models not present in the embedded \
265 catalog."
266 .to_string(),
267 category: "discovered".to_string(),
268 family: "discovered".to_string(),
269 auth_mode: "secret".to_string(),
270 key_var: String::new(),
271 literal_auth_token: String::new(),
272 base_url: String::new(),
273 default_model: String::new(),
274 model_tiers: HashMap::new(),
275 model_choices: vec![],
276 test_url: String::new(),
277 setup: vec![],
278 usage: vec![],
279 api_base_url: None,
280 npm_package: None,
281 doc_url: None,
282 models: vec![],
283 data_policy: None,
284 });
285 synth.models.push(model);
286 }
287 }
288 }
289
290 if let Some(synth) = synthetic {
291 entries.push(synth);
292 }
293
294 ProviderIndex::from_providers(entries)
295 }
296
297 pub fn all(&self) -> &[ServiceDescriptor] {
299 &self.entries
300 }
301
302 pub fn ids(&self) -> Vec<String> {
304 self.entries.iter().map(|p| p.id.clone()).collect()
305 }
306
307 pub fn get(&self, id: &str) -> Option<&ServiceDescriptor> {
309 self.index.get(id).map(|&i| &self.entries[i])
310 }
311
312 pub fn categories(&self) -> Vec<String> {
314 self.entries
315 .iter()
316 .scan(HashSet::new(), |seen, p| {
317 Some(if seen.insert(p.category.clone()) {
318 Some(p.category.clone())
319 } else {
320 None
321 })
322 })
323 .flatten()
324 .collect()
325 }
326
327 pub fn providers_by_category(&self, category: &str) -> Vec<&ServiceDescriptor> {
329 self.entries
330 .iter()
331 .filter(|p| p.category == category)
332 .collect()
333 }
334
335 pub fn builtin_secret_keys(&self) -> HashSet<String> {
337 self.entries
338 .iter()
339 .filter(|p| !p.key_var.is_empty())
340 .map(|p| p.key_var.clone())
341 .collect()
342 }
343
344 pub fn models_for(&self, provider_id: &str) -> &[ModelDescriptor] {
346 self.get(provider_id)
347 .map(|p| p.models.as_slice())
348 .unwrap_or(&[])
349 }
350
351 pub fn find_model(&self, model_id: &str) -> Option<(&ServiceDescriptor, &ModelDescriptor)> {
354 self.entries
355 .iter()
356 .find_map(|p| p.models.iter().find(|m| m.id == model_id).map(|m| (p, m)))
357 }
358
359 pub fn estimate_cost(
366 &self,
367 model_id: &str,
368 prompt_tokens: u32,
369 completion_tokens: u32,
370 ) -> Option<f64> {
371 let (_, model) = self.find_model(model_id)?;
372 let cost = model.cost.as_ref()?;
373 Some(
374 cost.input * prompt_tokens as f64 / 1_000_000.0
375 + cost.output * completion_tokens as f64 / 1_000_000.0,
376 )
377 }
378}
379
380static EMBEDDED: LazyLock<ProviderIndex> = LazyLock::new(|| {
382 let raw = include_str!("catalog.json");
383 let payload: IndexPayload = serde_json::from_str(raw).expect("catalog.json is valid");
384 ProviderIndex::from_payload(payload)
385});
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_embedded_loads() {
393 let catalog = ProviderIndex::embedded();
394 assert!(!catalog.all().is_empty());
395 }
396
397 #[test]
398 fn test_get_known_provider() {
399 let catalog = ProviderIndex::embedded();
400 let p = catalog.get("zai").expect("zai should exist");
402 assert_eq!(p.id, "zai");
403 assert!(!p.base_url.is_empty());
404 assert!(!p.default_model.is_empty());
405 }
406
407 #[test]
408 fn test_get_unknown_returns_none() {
409 let catalog = ProviderIndex::embedded();
410 assert!(catalog.get("nonexistent_provider_xyz").is_none());
411 }
412
413 #[test]
414 fn test_categories_no_duplicates() {
415 let catalog = ProviderIndex::embedded();
416 let cats = catalog.categories();
417 let mut seen = HashSet::new();
418 for c in &cats {
419 assert!(seen.insert(c.clone()), "duplicate category: {}", c);
420 }
421 }
422
423 #[test]
424 fn test_builtin_secret_keys() {
425 let catalog = ProviderIndex::embedded();
426 let keys = catalog.builtin_secret_keys();
427 assert!(!keys.is_empty(), "should contain at least one secret key");
428 assert!(
429 keys.contains("ZAI_API_KEY"),
430 "should contain ZAI_API_KEY, got: {:?}",
431 keys
432 );
433 }
434
435 #[test]
436 fn test_providers_by_category() {
437 let catalog = ProviderIndex::embedded();
438 let cats = catalog.categories();
439 if let Some(cat) = cats.first() {
440 let providers = catalog.providers_by_category(cat);
441 assert!(!providers.is_empty());
442 for p in &providers {
443 assert_eq!(p.category, *cat);
444 }
445 }
446 }
447
448 #[test]
449 fn test_models_for_provider() {
450 let catalog = ProviderIndex::embedded();
451 let models = catalog.models_for("zai");
452 assert!(!models.is_empty(), "zai should have models");
453 assert!(!models[0].id.is_empty());
455 }
456
457 #[test]
458 fn test_models_for_unknown_provider() {
459 let catalog = ProviderIndex::embedded();
460 let models = catalog.models_for("nonexistent_provider_xyz");
461 assert!(models.is_empty());
462 }
463
464 #[test]
465 fn test_find_model() {
466 let catalog = ProviderIndex::embedded();
467 let (provider, model) = catalog.find_model("glm-5").expect("glm-5 should be found");
468 assert_eq!(model.id, "glm-5");
469 assert!(
470 provider.id == "zai" || provider.id == "zai-cn",
471 "glm-5 should belong to a Z.AI provider, got: {}",
472 provider.id
473 );
474 }
475
476 #[test]
477 fn test_find_model_unknown() {
478 let catalog = ProviderIndex::embedded();
479 assert!(catalog.find_model("nonexistent-model-xyz").is_none());
480 }
481
482 #[test]
483 fn test_model_has_pricing() {
484 let catalog = ProviderIndex::embedded();
485 let (_, model) = catalog.find_model("glm-5").expect("glm-5 should exist");
486 let cost = model.cost.as_ref().expect("glm-5 should have cost");
487 assert!(cost.input > 0.0, "input cost should be positive");
488 assert!(cost.output > 0.0, "output cost should be positive");
489 }
490
491 #[test]
492 fn test_from_providers_round_trip() {
493 let original = ProviderIndex::embedded();
494 let rebuilt = ProviderIndex::from_providers(original.entries.clone());
495 assert_eq!(rebuilt.ids().len(), original.ids().len());
496 assert!(rebuilt.get("zai").is_some());
498 assert!(rebuilt.find_model("glm-5").is_some());
499 }
500
501 #[test]
502 fn test_from_providers_preserves_order() {
503 let providers = vec![
504 ServiceDescriptor {
505 id: "p1".to_string(),
506 display_name: "P1".to_string(),
507 description: String::new(),
508 category: "c".to_string(),
509 family: "f".to_string(),
510 auth_mode: "none".to_string(),
511 key_var: String::new(),
512 literal_auth_token: String::new(),
513 base_url: String::new(),
514 default_model: String::new(),
515 model_tiers: HashMap::new(),
516 model_choices: vec![],
517 test_url: String::new(),
518 setup: vec![],
519 usage: vec![],
520 api_base_url: None,
521 npm_package: None,
522 doc_url: None,
523 models: vec![],
524 data_policy: None,
525 },
526 ServiceDescriptor {
527 id: "p2".to_string(),
528 display_name: "P2".to_string(),
529 ..ProviderIndex::embedded().get("zai").unwrap().clone()
530 },
531 ];
532 let idx = ProviderIndex::from_providers(providers);
533 assert_eq!(idx.ids(), vec!["p1".to_string(), "p2".to_string()]);
534 assert_eq!(idx.get("p1").unwrap().display_name, "P1");
535 assert_eq!(idx.get("p2").unwrap().display_name, "P2");
536 }
537
538 #[cfg(feature = "discovery")]
539 #[test]
540 fn test_with_discovered_merges_and_enables_cost() {
541 use crate::discovery::{ModelEntry, ModelLimits};
542 use crate::provider::ModelCost;
543
544 let catalog = ProviderIndex::embedded();
545
546 let fresh = ModelEntry {
548 id: "future-model-xyz".to_string(),
549 name: "Future Model".to_string(),
550 provider_id: "zai".to_string(),
551 cost: Some(ModelCost {
552 input: 2.0,
553 output: 8.0,
554 cache_read: None,
555 cache_write: None,
556 }),
557 limits: Some(ModelLimits {
558 context: Some(100_000),
559 input: None,
560 output: Some(4_000),
561 }),
562 ..Default::default()
563 };
564 let orphan = ModelEntry {
566 id: "mystery/m1".to_string(),
567 name: "Mystery M1".to_string(),
568 provider_id: "mystery".to_string(),
569 cost: Some(ModelCost {
570 input: 1.0,
571 output: 1.0,
572 cache_read: None,
573 cache_write: None,
574 }),
575 ..Default::default()
576 };
577
578 let merged = catalog.with_discovered(&[fresh, orphan]);
579
580 assert!(merged.find_model("future-model-xyz").is_some());
582 assert_eq!(
583 merged.estimate_cost("future-model-xyz", 1_000_000, 1_000_000),
584 Some(10.0)
585 );
586
587 assert!(merged.get("discovered").is_some());
589 assert!(merged.find_model("mystery/m1").is_some());
590 assert_eq!(merged.estimate_cost("mystery/m1", 1_000_000, 0), Some(1.0));
591
592 assert!(catalog.find_model("future-model-xyz").is_none());
594 }
595}