Skip to main content

llm_kernel/provider/
catalog.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4
5// ---------------------------------------------------------------------------
6// models.dev-compatible model descriptor types
7// ---------------------------------------------------------------------------
8
9/// Per-million-token pricing for a model.
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
12pub struct ModelCost {
13    /// Price per million input (prompt) tokens in USD.
14    pub input: f64,
15    /// Price per million output (completion) tokens in USD.
16    pub output: f64,
17    /// Price per million cache-read tokens, if the provider supports prompt caching.
18    #[serde(default)]
19    pub cache_read: Option<f64>,
20    /// Price per million cache-write tokens, if the provider supports prompt caching.
21    #[serde(default)]
22    pub cache_write: Option<f64>,
23}
24
25/// Token limits for a model.
26#[non_exhaustive]
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
28pub struct ModelLimit {
29    /// Maximum context window in tokens (prompt + completion).
30    pub context: u64,
31    /// Maximum output (completion) tokens per request.
32    pub output: u64,
33}
34
35/// Input/output modalities a model supports.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
38pub struct ModelModalities {
39    /// Accepted input modalities (e.g. `["text", "image"]`).
40    pub input: Vec<String>,
41    /// Produced output modalities (e.g. `["text"]`).
42    pub output: Vec<String>,
43}
44
45/// Capability flags for a model.
46#[non_exhaustive]
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
48pub struct ModelCapabilities {
49    /// Whether the model accepts file/image attachments.
50    #[serde(default)]
51    pub attachment: bool,
52    /// Whether the model supports extended reasoning / chain-of-thought.
53    #[serde(default)]
54    pub reasoning: bool,
55    /// Whether the model accepts a `temperature` parameter.
56    #[serde(default)]
57    pub temperature: bool,
58    /// Whether the model supports tool/function calling.
59    #[serde(default)]
60    pub tool_call: bool,
61    /// Whether the model supports streaming responses (SSE).
62    #[serde(default = "default_true")]
63    pub streaming: bool,
64}
65
66fn default_true() -> bool {
67    true
68}
69
70/// A model offered by a provider (models.dev-compatible).
71#[non_exhaustive]
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
73pub struct ModelDescriptor {
74    /// Unique model identifier (e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`).
75    pub id: String,
76    /// Human-readable model name.
77    pub name: String,
78    /// Model family grouping (e.g. `"gpt-4"`, `"claude-3"`).
79    #[serde(default)]
80    pub family: Option<String>,
81    /// ISO 8601 date the model was released.
82    #[serde(default)]
83    pub release_date: Option<String>,
84    /// Pricing information per million tokens.
85    #[serde(default)]
86    pub cost: Option<ModelCost>,
87    /// Token limits for context and output.
88    #[serde(default)]
89    pub limit: Option<ModelLimit>,
90    /// Input and output modalities.
91    #[serde(default)]
92    pub modalities: Option<ModelModalities>,
93    /// Capability flags (tool calling, streaming, etc.).
94    #[serde(default)]
95    pub capabilities: Option<ModelCapabilities>,
96    /// Knowledge cutoff date (ISO 8601).
97    #[serde(default)]
98    pub knowledge: Option<String>,
99}
100
101// ---------------------------------------------------------------------------
102// Provider service descriptor
103// ---------------------------------------------------------------------------
104
105/// Describes an LLM provider service with all metadata needed to connect and use it.
106#[non_exhaustive]
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
108pub struct ServiceDescriptor {
109    /// Unique provider identifier (e.g. `"openai"`, `"anthropic"`).
110    pub id: String,
111    /// Human-readable display name.
112    #[serde(rename = "display_name")]
113    pub display_name: String,
114    /// Short description of the provider.
115    pub description: String,
116    /// Provider category (e.g. `"cloud"`, `"local"`).
117    pub category: String,
118    /// Provider family used to group related providers.
119    pub family: String,
120    /// Authentication mode: `"none"`, `"literal"`, or `"secret"`.
121    #[serde(rename = "auth_mode")]
122    pub auth_mode: String,
123    /// Environment variable name that holds the API key (empty if not required).
124    #[serde(rename = "key_var", skip_serializing_if = "String::is_empty", default)]
125    pub key_var: String,
126    /// Literal auth token embedded in the catalog (only set when `auth_mode = "literal"`).
127    #[serde(
128        rename = "literal_auth_token",
129        skip_serializing_if = "String::is_empty",
130        default
131    )]
132    pub literal_auth_token: String,
133    /// Base URL for the provider's web interface.
134    #[serde(rename = "base_url")]
135    pub base_url: String,
136    /// Default model ID used when no model override is specified.
137    #[serde(rename = "default_model")]
138    pub default_model: String,
139    /// Named model tiers mapping tier name → model ID (e.g. `"fast"` → `"gpt-4o-mini"`).
140    #[serde(rename = "model_tiers", default)]
141    pub model_tiers: HashMap<String, String>,
142    /// Legacy list of available model choices (claudy-specific).
143    #[serde(rename = "model_choices", default)]
144    pub model_choices: Vec<ModelChoice>,
145    /// URL used to test connectivity to the provider.
146    #[serde(rename = "test_url")]
147    pub test_url: String,
148    /// Setup instructions shown to the user during first-time configuration.
149    #[serde(default)]
150    pub setup: Vec<String>,
151    /// Usage examples shown to the user in the install wizard.
152    #[serde(default)]
153    pub usage: Vec<String>,
154
155    // models.dev-compatible fields
156    /// API base URL override (models.dev-compatible field).
157    #[serde(default)]
158    pub api_base_url: Option<String>,
159    /// npm package name (models.dev-compatible field, for AI coding tools).
160    #[serde(default)]
161    pub npm_package: Option<String>,
162    /// Link to provider documentation.
163    #[serde(default)]
164    pub doc_url: Option<String>,
165    /// Full list of models offered by this provider.
166    #[serde(default)]
167    pub models: Vec<ModelDescriptor>,
168    /// Data-loss-prevention policy for this provider. Absent in the shipped
169    /// catalog — `DataPolicy::default_for` supplies code-level defaults.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub data_policy: Option<crate::provider::policy::DataPolicy>,
172}
173
174/// Legacy model choice (claudy-specific: id + description).
175/// Retained for backward compatibility with existing catalog.json entries.
176#[non_exhaustive]
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
178pub struct ModelChoice {
179    /// Model identifier.
180    pub id: String,
181    /// Short description of the model.
182    pub description: String,
183}
184
185#[derive(Debug, Serialize, Deserialize)]
186struct IndexPayload {
187    providers: Vec<ServiceDescriptor>,
188}
189
190// ---------------------------------------------------------------------------
191// Provider index
192// ---------------------------------------------------------------------------
193
194/// Immutable provider catalog with O(1) lookup by id.
195///
196/// The catalog is compiled into the binary from `catalog.json` via `include_str!`.
197/// Access it through [`ProviderIndex::embedded()`].
198#[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    /// Build a [`ProviderIndex`] from an explicit list of providers.
210    ///
211    /// Useful for tests, overlays, or merging discovered providers into the
212    /// embedded catalog. Provider order is preserved. The embedded catalog has
213    /// no duplicate ids; if duplicates are passed, [`ProviderIndex::get`]
214    /// resolves to the last occurrence while [`ProviderIndex::all`] retains
215    /// every entry.
216    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    /// Access the static catalog embedded at compile time.
229    pub fn embedded() -> &'static ProviderIndex {
230        &EMBEDDED
231    }
232
233    /// Return a new catalog where discovered model entries overlay this one.
234    ///
235    /// For a discovered entry whose `provider_id` matches an existing provider,
236    /// its model is merged into that provider (replacing on id collision,
237    /// appending otherwise). Entries whose `provider_id` is not in the catalog
238    /// are gathered under a synthetic `"discovered"` provider.
239    ///
240    /// This resolves the catalog↔discovery gap: once merged, discovered models
241    /// are visible to [`ProviderIndex::find_model`] and
242    /// [`ProviderIndex::estimate_cost`]. The catalog is not mutated; an owned
243    /// [`ProviderIndex`] is returned.
244    #[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    /// Return all providers in catalog order.
298    pub fn all(&self) -> &[ServiceDescriptor] {
299        &self.entries
300    }
301
302    /// Return all provider IDs.
303    pub fn ids(&self) -> Vec<String> {
304        self.entries.iter().map(|p| p.id.clone()).collect()
305    }
306
307    /// Look up a provider by ID. O(1).
308    pub fn get(&self, id: &str) -> Option<&ServiceDescriptor> {
309        self.index.get(id).map(|&i| &self.entries[i])
310    }
311
312    /// Unique categories in catalog order.
313    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    /// Filter providers by category.
328    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    /// Collect all secret key variable names from providers that require one.
336    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    /// Get models for a specific provider.
345    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    /// Find a model by ID across all providers.
352    /// Returns the first match (provider, model).
353    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    /// Estimate the USD cost of an LLM call given token counts.
360    ///
361    /// Looks up `model_id` across all providers and computes:
362    /// `(input_price * prompt_tokens + output_price * completion_tokens) / 1_000_000`
363    ///
364    /// Returns `None` if the model is not found or has no pricing data.
365    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
380/// Static catalog compiled into the binary from `catalog.json`.
381static 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        // catalog.json contains "zai" (first provider with key_var)
401        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        // First model should have an id
454        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        // O(1) lookup survives reconstruction.
497        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        // A model not in the static catalog, attached to an existing provider.
547        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        // A model under a provider absent from the catalog → synthetic bucket.
565        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        // fresh merged into existing zai → estimate_cost now works.
581        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        // orphan landed under a synthetic "discovered" provider.
588        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        // The embedded static catalog is not mutated.
593        assert!(catalog.find_model("future-model-xyz").is_none());
594    }
595}