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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct ModelCost {
12    /// Price per million input (prompt) tokens in USD.
13    pub input: f64,
14    /// Price per million output (completion) tokens in USD.
15    pub output: f64,
16    /// Price per million cache-read tokens, if the provider supports prompt caching.
17    #[serde(default)]
18    pub cache_read: Option<f64>,
19    /// Price per million cache-write tokens, if the provider supports prompt caching.
20    #[serde(default)]
21    pub cache_write: Option<f64>,
22}
23
24/// Token limits for a model.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ModelLimit {
27    /// Maximum context window in tokens (prompt + completion).
28    pub context: u64,
29    /// Maximum output (completion) tokens per request.
30    pub output: u64,
31}
32
33/// Input/output modalities a model supports.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ModelModalities {
36    /// Accepted input modalities (e.g. `["text", "image"]`).
37    pub input: Vec<String>,
38    /// Produced output modalities (e.g. `["text"]`).
39    pub output: Vec<String>,
40}
41
42/// Capability flags for a model.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct ModelCapabilities {
45    /// Whether the model accepts file/image attachments.
46    #[serde(default)]
47    pub attachment: bool,
48    /// Whether the model supports extended reasoning / chain-of-thought.
49    #[serde(default)]
50    pub reasoning: bool,
51    /// Whether the model accepts a `temperature` parameter.
52    #[serde(default)]
53    pub temperature: bool,
54    /// Whether the model supports tool/function calling.
55    #[serde(default)]
56    pub tool_call: bool,
57    /// Whether the model supports streaming responses (SSE).
58    #[serde(default = "default_true")]
59    pub streaming: bool,
60}
61
62fn default_true() -> bool {
63    true
64}
65
66/// A model offered by a provider (models.dev-compatible).
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct ModelDescriptor {
69    /// Unique model identifier (e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`).
70    pub id: String,
71    /// Human-readable model name.
72    pub name: String,
73    /// Model family grouping (e.g. `"gpt-4"`, `"claude-3"`).
74    #[serde(default)]
75    pub family: Option<String>,
76    /// ISO 8601 date the model was released.
77    #[serde(default)]
78    pub release_date: Option<String>,
79    /// Pricing information per million tokens.
80    #[serde(default)]
81    pub cost: Option<ModelCost>,
82    /// Token limits for context and output.
83    #[serde(default)]
84    pub limit: Option<ModelLimit>,
85    /// Input and output modalities.
86    #[serde(default)]
87    pub modalities: Option<ModelModalities>,
88    /// Capability flags (tool calling, streaming, etc.).
89    #[serde(default)]
90    pub capabilities: Option<ModelCapabilities>,
91    /// Knowledge cutoff date (ISO 8601).
92    #[serde(default)]
93    pub knowledge: Option<String>,
94}
95
96// ---------------------------------------------------------------------------
97// Provider service descriptor
98// ---------------------------------------------------------------------------
99
100/// Describes an LLM provider service with all metadata needed to connect and use it.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct ServiceDescriptor {
103    /// Unique provider identifier (e.g. `"openai"`, `"anthropic"`).
104    pub id: String,
105    /// Human-readable display name.
106    #[serde(rename = "display_name")]
107    pub display_name: String,
108    /// Short description of the provider.
109    pub description: String,
110    /// Provider category (e.g. `"cloud"`, `"local"`).
111    pub category: String,
112    /// Provider family used to group related providers.
113    pub family: String,
114    /// Authentication mode: `"none"`, `"literal"`, or `"secret"`.
115    #[serde(rename = "auth_mode")]
116    pub auth_mode: String,
117    /// Environment variable name that holds the API key (empty if not required).
118    #[serde(rename = "key_var", skip_serializing_if = "String::is_empty", default)]
119    pub key_var: String,
120    /// Literal auth token embedded in the catalog (only set when `auth_mode = "literal"`).
121    #[serde(
122        rename = "literal_auth_token",
123        skip_serializing_if = "String::is_empty",
124        default
125    )]
126    pub literal_auth_token: String,
127    /// Base URL for the provider's web interface.
128    #[serde(rename = "base_url")]
129    pub base_url: String,
130    /// Default model ID used when no model override is specified.
131    #[serde(rename = "default_model")]
132    pub default_model: String,
133    /// Named model tiers mapping tier name → model ID (e.g. `"fast"` → `"gpt-4o-mini"`).
134    #[serde(rename = "model_tiers", default)]
135    pub model_tiers: HashMap<String, String>,
136    /// Legacy list of available model choices (claudy-specific).
137    #[serde(rename = "model_choices", default)]
138    pub model_choices: Vec<ModelChoice>,
139    /// URL used to test connectivity to the provider.
140    #[serde(rename = "test_url")]
141    pub test_url: String,
142    /// Setup instructions shown to the user during first-time configuration.
143    #[serde(default)]
144    pub setup: Vec<String>,
145    /// Usage examples shown to the user in the install wizard.
146    #[serde(default)]
147    pub usage: Vec<String>,
148
149    // models.dev-compatible fields
150    /// API base URL override (models.dev-compatible field).
151    #[serde(default)]
152    pub api_base_url: Option<String>,
153    /// npm package name (models.dev-compatible field, for AI coding tools).
154    #[serde(default)]
155    pub npm_package: Option<String>,
156    /// Link to provider documentation.
157    #[serde(default)]
158    pub doc_url: Option<String>,
159    /// Full list of models offered by this provider.
160    #[serde(default)]
161    pub models: Vec<ModelDescriptor>,
162}
163
164/// Legacy model choice (claudy-specific: id + description).
165/// Retained for backward compatibility with existing catalog.json entries.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct ModelChoice {
168    /// Model identifier.
169    pub id: String,
170    /// Short description of the model.
171    pub description: String,
172}
173
174#[derive(Debug, Serialize, Deserialize)]
175struct IndexPayload {
176    providers: Vec<ServiceDescriptor>,
177}
178
179// ---------------------------------------------------------------------------
180// Provider index
181// ---------------------------------------------------------------------------
182
183/// Immutable provider catalog with O(1) lookup by id.
184///
185/// The catalog is compiled into the binary from `catalog.json` via `include_str!`.
186/// Access it through [`ProviderIndex::embedded()`].
187#[derive(Debug, Clone)]
188pub struct ProviderIndex {
189    entries: Vec<ServiceDescriptor>,
190    index: HashMap<String, usize>,
191}
192
193impl ProviderIndex {
194    fn from_payload(payload: IndexPayload) -> Self {
195        Self::from_providers(payload.providers)
196    }
197
198    /// Build a [`ProviderIndex`] from an explicit list of providers.
199    ///
200    /// Useful for tests, overlays, or merging discovered providers into the
201    /// embedded catalog. Provider order is preserved. The embedded catalog has
202    /// no duplicate ids; if duplicates are passed, [`ProviderIndex::get`]
203    /// resolves to the last occurrence while [`ProviderIndex::all`] retains
204    /// every entry.
205    pub fn from_providers(providers: Vec<ServiceDescriptor>) -> Self {
206        let index: HashMap<String, usize> = providers
207            .iter()
208            .enumerate()
209            .map(|(i, p)| (p.id.clone(), i))
210            .collect();
211        Self {
212            entries: providers,
213            index,
214        }
215    }
216
217    /// Access the static catalog embedded at compile time.
218    pub fn embedded() -> &'static ProviderIndex {
219        &EMBEDDED
220    }
221
222    /// Return a new catalog where discovered model entries overlay this one.
223    ///
224    /// For a discovered entry whose `provider_id` matches an existing provider,
225    /// its model is merged into that provider (replacing on id collision,
226    /// appending otherwise). Entries whose `provider_id` is not in the catalog
227    /// are gathered under a synthetic `"discovered"` provider.
228    ///
229    /// This resolves the catalog↔discovery gap: once merged, discovered models
230    /// are visible to [`ProviderIndex::find_model`] and
231    /// [`ProviderIndex::estimate_cost`]. The catalog is not mutated; an owned
232    /// [`ProviderIndex`] is returned.
233    #[cfg(feature = "discovery")]
234    pub fn with_discovered(&self, discovered: &[crate::discovery::ModelEntry]) -> ProviderIndex {
235        let mut entries: Vec<ServiceDescriptor> = self.entries.clone();
236        let mut synthetic: Option<ServiceDescriptor> = None;
237
238        for entry in discovered {
239            let model: ModelDescriptor = entry.clone().into();
240            match self.index.get(&entry.provider_id).copied() {
241                Some(idx) => {
242                    let provider = &mut entries[idx];
243                    if let Some(pos) = provider.models.iter().position(|m| m.id == model.id) {
244                        provider.models[pos] = model;
245                    } else {
246                        provider.models.push(model);
247                    }
248                }
249                None => {
250                    let synth = synthetic.get_or_insert_with(|| ServiceDescriptor {
251                        id: "discovered".to_string(),
252                        display_name: "Discovered".to_string(),
253                        description: "Runtime-discovered models not present in the embedded \
254                                      catalog."
255                            .to_string(),
256                        category: "discovered".to_string(),
257                        family: "discovered".to_string(),
258                        auth_mode: "secret".to_string(),
259                        key_var: String::new(),
260                        literal_auth_token: String::new(),
261                        base_url: String::new(),
262                        default_model: String::new(),
263                        model_tiers: HashMap::new(),
264                        model_choices: vec![],
265                        test_url: String::new(),
266                        setup: vec![],
267                        usage: vec![],
268                        api_base_url: None,
269                        npm_package: None,
270                        doc_url: None,
271                        models: vec![],
272                    });
273                    synth.models.push(model);
274                }
275            }
276        }
277
278        if let Some(synth) = synthetic {
279            entries.push(synth);
280        }
281
282        ProviderIndex::from_providers(entries)
283    }
284
285    /// Return all providers in catalog order.
286    pub fn all(&self) -> &[ServiceDescriptor] {
287        &self.entries
288    }
289
290    /// Return all provider IDs.
291    pub fn ids(&self) -> Vec<String> {
292        self.entries.iter().map(|p| p.id.clone()).collect()
293    }
294
295    /// Look up a provider by ID. O(1).
296    pub fn get(&self, id: &str) -> Option<&ServiceDescriptor> {
297        self.index.get(id).map(|&i| &self.entries[i])
298    }
299
300    /// Unique categories in catalog order.
301    pub fn categories(&self) -> Vec<String> {
302        self.entries
303            .iter()
304            .scan(HashSet::new(), |seen, p| {
305                Some(if seen.insert(p.category.clone()) {
306                    Some(p.category.clone())
307                } else {
308                    None
309                })
310            })
311            .flatten()
312            .collect()
313    }
314
315    /// Filter providers by category.
316    pub fn providers_by_category(&self, category: &str) -> Vec<&ServiceDescriptor> {
317        self.entries
318            .iter()
319            .filter(|p| p.category == category)
320            .collect()
321    }
322
323    /// Collect all secret key variable names from providers that require one.
324    pub fn builtin_secret_keys(&self) -> HashSet<String> {
325        self.entries
326            .iter()
327            .filter(|p| !p.key_var.is_empty())
328            .map(|p| p.key_var.clone())
329            .collect()
330    }
331
332    /// Get models for a specific provider.
333    pub fn models_for(&self, provider_id: &str) -> &[ModelDescriptor] {
334        self.get(provider_id)
335            .map(|p| p.models.as_slice())
336            .unwrap_or(&[])
337    }
338
339    /// Find a model by ID across all providers.
340    /// Returns the first match (provider, model).
341    pub fn find_model(&self, model_id: &str) -> Option<(&ServiceDescriptor, &ModelDescriptor)> {
342        self.entries
343            .iter()
344            .find_map(|p| p.models.iter().find(|m| m.id == model_id).map(|m| (p, m)))
345    }
346
347    /// Estimate the USD cost of an LLM call given token counts.
348    ///
349    /// Looks up `model_id` across all providers and computes:
350    /// `(input_price * prompt_tokens + output_price * completion_tokens) / 1_000_000`
351    ///
352    /// Returns `None` if the model is not found or has no pricing data.
353    pub fn estimate_cost(
354        &self,
355        model_id: &str,
356        prompt_tokens: u32,
357        completion_tokens: u32,
358    ) -> Option<f64> {
359        let (_, model) = self.find_model(model_id)?;
360        let cost = model.cost.as_ref()?;
361        Some(
362            cost.input * prompt_tokens as f64 / 1_000_000.0
363                + cost.output * completion_tokens as f64 / 1_000_000.0,
364        )
365    }
366}
367
368/// Static catalog compiled into the binary from `catalog.json`.
369static EMBEDDED: LazyLock<ProviderIndex> = LazyLock::new(|| {
370    let raw = include_str!("catalog.json");
371    let payload: IndexPayload = serde_json::from_str(raw).expect("catalog.json is valid");
372    ProviderIndex::from_payload(payload)
373});
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn test_embedded_loads() {
381        let catalog = ProviderIndex::embedded();
382        assert!(!catalog.all().is_empty());
383    }
384
385    #[test]
386    fn test_get_known_provider() {
387        let catalog = ProviderIndex::embedded();
388        // catalog.json contains "zai" (first provider with key_var)
389        let p = catalog.get("zai").expect("zai should exist");
390        assert_eq!(p.id, "zai");
391        assert!(!p.base_url.is_empty());
392        assert!(!p.default_model.is_empty());
393    }
394
395    #[test]
396    fn test_get_unknown_returns_none() {
397        let catalog = ProviderIndex::embedded();
398        assert!(catalog.get("nonexistent_provider_xyz").is_none());
399    }
400
401    #[test]
402    fn test_categories_no_duplicates() {
403        let catalog = ProviderIndex::embedded();
404        let cats = catalog.categories();
405        let mut seen = HashSet::new();
406        for c in &cats {
407            assert!(seen.insert(c.clone()), "duplicate category: {}", c);
408        }
409    }
410
411    #[test]
412    fn test_builtin_secret_keys() {
413        let catalog = ProviderIndex::embedded();
414        let keys = catalog.builtin_secret_keys();
415        assert!(!keys.is_empty(), "should contain at least one secret key");
416        assert!(
417            keys.contains("ZAI_API_KEY"),
418            "should contain ZAI_API_KEY, got: {:?}",
419            keys
420        );
421    }
422
423    #[test]
424    fn test_providers_by_category() {
425        let catalog = ProviderIndex::embedded();
426        let cats = catalog.categories();
427        if let Some(cat) = cats.first() {
428            let providers = catalog.providers_by_category(cat);
429            assert!(!providers.is_empty());
430            for p in &providers {
431                assert_eq!(p.category, *cat);
432            }
433        }
434    }
435
436    #[test]
437    fn test_models_for_provider() {
438        let catalog = ProviderIndex::embedded();
439        let models = catalog.models_for("zai");
440        assert!(!models.is_empty(), "zai should have models");
441        // First model should have an id
442        assert!(!models[0].id.is_empty());
443    }
444
445    #[test]
446    fn test_models_for_unknown_provider() {
447        let catalog = ProviderIndex::embedded();
448        let models = catalog.models_for("nonexistent_provider_xyz");
449        assert!(models.is_empty());
450    }
451
452    #[test]
453    fn test_find_model() {
454        let catalog = ProviderIndex::embedded();
455        let (provider, model) = catalog.find_model("glm-5").expect("glm-5 should be found");
456        assert_eq!(model.id, "glm-5");
457        assert!(
458            provider.id == "zai" || provider.id == "zai-cn",
459            "glm-5 should belong to a Z.AI provider, got: {}",
460            provider.id
461        );
462    }
463
464    #[test]
465    fn test_find_model_unknown() {
466        let catalog = ProviderIndex::embedded();
467        assert!(catalog.find_model("nonexistent-model-xyz").is_none());
468    }
469
470    #[test]
471    fn test_model_has_pricing() {
472        let catalog = ProviderIndex::embedded();
473        let (_, model) = catalog.find_model("glm-5").expect("glm-5 should exist");
474        let cost = model.cost.as_ref().expect("glm-5 should have cost");
475        assert!(cost.input > 0.0, "input cost should be positive");
476        assert!(cost.output > 0.0, "output cost should be positive");
477    }
478
479    #[test]
480    fn test_from_providers_round_trip() {
481        let original = ProviderIndex::embedded();
482        let rebuilt = ProviderIndex::from_providers(original.entries.clone());
483        assert_eq!(rebuilt.ids().len(), original.ids().len());
484        // O(1) lookup survives reconstruction.
485        assert!(rebuilt.get("zai").is_some());
486        assert!(rebuilt.find_model("glm-5").is_some());
487    }
488
489    #[test]
490    fn test_from_providers_preserves_order() {
491        let providers = vec![
492            ServiceDescriptor {
493                id: "p1".to_string(),
494                display_name: "P1".to_string(),
495                description: String::new(),
496                category: "c".to_string(),
497                family: "f".to_string(),
498                auth_mode: "none".to_string(),
499                key_var: String::new(),
500                literal_auth_token: String::new(),
501                base_url: String::new(),
502                default_model: String::new(),
503                model_tiers: HashMap::new(),
504                model_choices: vec![],
505                test_url: String::new(),
506                setup: vec![],
507                usage: vec![],
508                api_base_url: None,
509                npm_package: None,
510                doc_url: None,
511                models: vec![],
512            },
513            ServiceDescriptor {
514                id: "p2".to_string(),
515                display_name: "P2".to_string(),
516                ..ProviderIndex::embedded().get("zai").unwrap().clone()
517            },
518        ];
519        let idx = ProviderIndex::from_providers(providers);
520        assert_eq!(idx.ids(), vec!["p1".to_string(), "p2".to_string()]);
521        assert_eq!(idx.get("p1").unwrap().display_name, "P1");
522        assert_eq!(idx.get("p2").unwrap().display_name, "P2");
523    }
524
525    #[cfg(feature = "discovery")]
526    #[test]
527    fn test_with_discovered_merges_and_enables_cost() {
528        use crate::discovery::{ModelEntry, ModelLimits};
529        use crate::provider::ModelCost;
530
531        let catalog = ProviderIndex::embedded();
532
533        // A model not in the static catalog, attached to an existing provider.
534        let fresh = ModelEntry {
535            id: "future-model-xyz".to_string(),
536            name: "Future Model".to_string(),
537            provider_id: "zai".to_string(),
538            cost: Some(ModelCost {
539                input: 2.0,
540                output: 8.0,
541                cache_read: None,
542                cache_write: None,
543            }),
544            limits: Some(ModelLimits {
545                context: Some(100_000),
546                input: None,
547                output: Some(4_000),
548            }),
549            ..Default::default()
550        };
551        // A model under a provider absent from the catalog → synthetic bucket.
552        let orphan = ModelEntry {
553            id: "mystery/m1".to_string(),
554            name: "Mystery M1".to_string(),
555            provider_id: "mystery".to_string(),
556            cost: Some(ModelCost {
557                input: 1.0,
558                output: 1.0,
559                cache_read: None,
560                cache_write: None,
561            }),
562            ..Default::default()
563        };
564
565        let merged = catalog.with_discovered(&[fresh, orphan]);
566
567        // fresh merged into existing zai → estimate_cost now works.
568        assert!(merged.find_model("future-model-xyz").is_some());
569        assert_eq!(
570            merged.estimate_cost("future-model-xyz", 1_000_000, 1_000_000),
571            Some(10.0)
572        );
573
574        // orphan landed under a synthetic "discovered" provider.
575        assert!(merged.get("discovered").is_some());
576        assert!(merged.find_model("mystery/m1").is_some());
577        assert_eq!(merged.estimate_cost("mystery/m1", 1_000_000, 0), Some(1.0));
578
579        // The embedded static catalog is not mutated.
580        assert!(catalog.find_model("future-model-xyz").is_none());
581    }
582}