Skip to main content

lc_core/
model_registry.rs

1//! Dynamic model registry (B3, 0.22.4).
2//!
3//! A [`ModelRegistry`] catalogs models independently of concrete clients:
4//! provider slug, model id, context window, output cap, feature flags and
5//! [`ModelPrice`]. Routers ([`crate::router_llm::RouterLLM`]) read prices from
6//! a shared registry; callers can `register` custom/self-hosted models or
7//! [`ModelRegistry::fetch`] a JSON catalog over HTTP, so price/capability
8//! updates need no library release.
9//!
10//! Remote catalog JSON shape:
11//!
12//! ```json
13//! {
14//!   "version": 1,
15//!   "models": [
16//!     {
17//!       "provider": "openai",
18//!       "id": "gpt-4o-mini",
19//!       "context_window": 128000,
20//!       "max_output_tokens": 16384,
21//!       "capabilities": {
22//!         "tools": true,
23//!         "vision": false,
24//!         "json_mode": true,
25//!         "reasoning": false,
26//!         "audio": false
27//!       },
28//!       "price": { "input_per_1k": 0.15, "output_per_1k": 0.60 }
29//!     }
30//!   ]
31//! }
32//! ```
33
34use std::collections::HashMap;
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38
39use crate::cost::{CostError, ModelPrice};
40
41/// Feature flags advertised by a model.
42///
43/// All fields default to `false` when omitted from a remote payload, so newly
44/// added capabilities parse safely against older catalogs.
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ModelCapabilities {
47    /// Native function/tool calling.
48    #[serde(default)]
49    pub tools: bool,
50    /// Image (vision) inputs.
51    #[serde(default)]
52    pub vision: bool,
53    /// Strict JSON / structured output mode.
54    #[serde(default)]
55    pub json_mode: bool,
56    /// Reasoning/thinking model.
57    #[serde(default)]
58    pub reasoning: bool,
59    /// Audio input/output.
60    #[serde(default)]
61    pub audio: bool,
62}
63
64/// One catalog entry.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ModelInfo {
67    /// Provider slug (`"openai"`, `"anthropic"`, `"groq"`, `"custom"` ...).
68    pub provider: String,
69    /// Model id as used in API calls.
70    pub id: String,
71    /// Maximum context window in tokens.
72    pub context_window: usize,
73    /// Maximum output tokens the model accepts; `None` when undeclared.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub max_output_tokens: Option<usize>,
76    /// Capability flags.
77    #[serde(default)]
78    pub capabilities: ModelCapabilities,
79    /// USD price per 1K tokens.
80    pub price: ModelPrice,
81}
82
83impl ModelInfo {
84    /// Creates an entry with the given essentials and no capability flags.
85    pub fn new(
86        provider: impl Into<String>,
87        id: impl Into<String>,
88        context_window: usize,
89        price: ModelPrice,
90    ) -> Self {
91        Self {
92            provider: provider.into(),
93            id: id.into(),
94            context_window,
95            max_output_tokens: None,
96            capabilities: ModelCapabilities::default(),
97            price,
98        }
99    }
100
101    /// Builder-style setter for the output cap.
102    pub fn with_max_output(mut self, max_output_tokens: usize) -> Self {
103        self.max_output_tokens = Some(max_output_tokens);
104        self
105    }
106
107    /// Builder-style setter for capability flags.
108    pub fn with_capabilities(mut self, capabilities: ModelCapabilities) -> Self {
109        self.capabilities = capabilities;
110        self
111    }
112
113    /// The canonical `"<provider>/<id>"` key.
114    pub fn key(&self) -> String {
115        format!("{}/{}", self.provider, self.id)
116    }
117}
118
119/// Wire format of a remote catalog.
120#[derive(Debug, Clone, Deserialize)]
121struct CatalogEnvelope {
122    #[serde(default)]
123    #[allow(dead_code)]
124    version: Option<u32>,
125    models: Vec<ModelInfo>,
126}
127
128/// Provider/model catalog. Cheap to clone (entries shared via `Arc`).
129#[derive(Debug, Clone, Default)]
130pub struct ModelRegistry {
131    models: HashMap<String, Arc<ModelInfo>>,
132}
133
134impl ModelRegistry {
135    /// Empty registry.
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Adds/overwrites one entry.
141    pub fn register(&mut self, info: ModelInfo) {
142        self.models.insert(info.key(), Arc::new(info));
143    }
144
145    /// Builder-style entry registration.
146    pub fn with(mut self, info: ModelInfo) -> Self {
147        self.register(info);
148        self
149    }
150
151    /// Merges every entry of `other` into this registry (other wins on key
152    /// collision — a remote catalog can therefore override built-in prices).
153    pub fn merge(&mut self, other: ModelRegistry) {
154        for (key, info) in other.models {
155            self.models.insert(key, info);
156        }
157    }
158
159    /// Lookup by provider + model id.
160    pub fn get(&self, provider: &str, model: &str) -> Option<&Arc<ModelInfo>> {
161        self.models.get(&format!("{provider}/{model}"))
162    }
163
164    /// Lookup by canonical `"<provider>/<id>"` key.
165    pub fn get_by_key(&self, key: &str) -> Option<&Arc<ModelInfo>> {
166        self.models.get(key)
167    }
168
169    /// All registered entries (arbitrary order).
170    pub fn models(&self) -> impl Iterator<Item = &Arc<ModelInfo>> {
171        self.models.values()
172    }
173
174    /// Entries of one provider.
175    pub fn models_of<'a>(
176        &'a self,
177        provider: &'a str,
178    ) -> impl Iterator<Item = &'a Arc<ModelInfo>> + 'a {
179        self.models.values().filter(move |m| m.provider == provider)
180    }
181
182    /// Number of entries.
183    pub fn len(&self) -> usize {
184        self.models.len()
185    }
186
187    /// Whether the registry is empty.
188    pub fn is_empty(&self) -> bool {
189        self.models.is_empty()
190    }
191
192    /// Parses a remote/JSON catalog (`{"version":..,"models":[...]}` or a bare
193    /// `[ModelInfo]` array).
194    pub fn from_json(json: &str) -> Result<Self, CostError> {
195        // Accept both the enveloped object and a bare array.
196        if let Ok(envelope) = serde_json::from_str::<CatalogEnvelope>(json) {
197            return Ok(Self::from_iter(envelope.models));
198        }
199        let models: Vec<ModelInfo> = serde_json::from_str(json)
200            .map_err(|e| CostError::Payload(format!("catalog JSON parse failed: {e}")))?;
201        Ok(Self::from_iter(models))
202    }
203
204    /// Serializes the registry to the enveloped catalog JSON.
205    pub fn to_json(&self) -> Result<String, CostError> {
206        let models: Vec<&ModelInfo> = self.models.values().map(AsRef::as_ref).collect();
207        Ok(serde_json::json!({ "version": 1, "models": models }).to_string())
208    }
209
210    /// Downloads and parses a remote catalog with a fresh blocking-capable
211    /// `reqwest` client.
212    pub async fn fetch(url: &str) -> Result<Self, CostError> {
213        let client = reqwest::Client::builder()
214            .build()
215            .map_err(|e| CostError::Fetch(e.to_string()))?;
216        Self::fetch_with_client(&client, url).await
217    }
218
219    /// Downloads and parses a remote catalog with a caller-provided client
220    /// (shared connection pool, custom timeouts/proxies/auth headers).
221    pub async fn fetch_with_client(client: &reqwest::Client, url: &str) -> Result<Self, CostError> {
222        let resp = client
223            .get(url)
224            .send()
225            .await
226            .map_err(|e| CostError::Fetch(e.to_string()))?;
227        let status = resp.status();
228        if !status.is_success() {
229            return Err(CostError::Fetch(format!(
230                "catalog GET {url} returned HTTP {status}"
231            )));
232        }
233        let body = resp
234            .text()
235            .await
236            .map_err(|e| CostError::Fetch(e.to_string()))?;
237        Self::from_json(&body)
238    }
239
240    fn from_iter(iter: impl IntoIterator<Item = ModelInfo>) -> Self {
241        let mut registry = Self::new();
242        for info in iter {
243            registry.register(info);
244        }
245        registry
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn sample() -> ModelInfo {
254        ModelInfo::new("openai", "gpt-x", 128_000, ModelPrice::new(1.0, 4.0))
255            .with_max_output(16_384)
256            .with_capabilities(ModelCapabilities {
257                tools: true,
258                vision: false,
259                json_mode: true,
260                reasoning: false,
261                audio: false,
262            })
263    }
264
265    #[test]
266    fn register_and_lookup() {
267        let registry = ModelRegistry::new().with(sample());
268        assert_eq!(registry.len(), 1);
269        let info = registry.get("openai", "gpt-x").expect("registered");
270        assert_eq!(info.context_window, 128_000);
271        assert_eq!(info.max_output_tokens, Some(16_384));
272        assert!(info.capabilities.tools);
273        assert!(registry.get_by_key("openai/gpt-x").is_some());
274        assert!(registry.get("anthropic", "gpt-x").is_none());
275        assert_eq!(registry.models_of("openai").count(), 1);
276        assert_eq!(registry.models_of("google").count(), 0);
277    }
278
279    #[test]
280    fn merge_other_wins_on_collision() {
281        let mut base =
282            ModelRegistry::new().with(ModelInfo::new("p", "m", 1000, ModelPrice::new(1.0, 1.0)));
283        let newer =
284            ModelRegistry::new().with(ModelInfo::new("p", "m", 2000, ModelPrice::new(2.0, 2.0)));
285        base.merge(newer);
286        assert_eq!(base.get("p", "m").unwrap().context_window, 2000);
287    }
288
289    #[test]
290    fn parses_enveloped_json_with_defaults() {
291        let json = serde_json::json!({
292            "version": 1,
293            "models": [
294                {
295                    "provider": "custom",
296                    "id": "local-llm",
297                    "context_window": 32768,
298                    "price": { "input_per_1k": 0.0, "output_per_1k": 0.0 }
299                }
300            ]
301        })
302        .to_string();
303        let registry = ModelRegistry::from_json(&json).unwrap();
304        let info = registry.get("custom", "local-llm").unwrap();
305        assert_eq!(info.context_window, 32768);
306        assert_eq!(info.max_output_tokens, None);
307        assert!(!info.capabilities.tools);
308        assert_eq!(info.price, ModelPrice::free());
309    }
310
311    #[test]
312    fn parses_bare_array_json() {
313        let json = serde_json::json!([
314            {
315                "provider": "groq",
316                "id": "llama-x",
317                "context_window": 131072,
318                "capabilities": { "tools": true },
319                "price": { "input_per_1k": 0.59, "output_per_1k": 0.79 }
320            }
321        ])
322        .to_string();
323        let registry = ModelRegistry::from_json(&json).unwrap();
324        assert_eq!(registry.len(), 1);
325        assert!(registry.get("groq", "llama-x").unwrap().capabilities.tools);
326    }
327
328    #[test]
329    fn invalid_json_is_payload_error() {
330        let err = ModelRegistry::from_json("{not json").unwrap_err();
331        assert!(matches!(err, CostError::Payload(_)));
332    }
333
334    #[test]
335    fn round_trips_through_json() {
336        let registry = ModelRegistry::new().with(sample());
337        let json = registry.to_json().unwrap();
338        let parsed = ModelRegistry::from_json(&json).unwrap();
339        assert_eq!(
340            parsed.get("openai", "gpt-x").unwrap().as_ref(),
341            registry.get("openai", "gpt-x").unwrap().as_ref()
342        );
343    }
344
345    #[tokio::test]
346    async fn fetch_reports_http_errors_as_fetch_error() {
347        // Unroutable port → connection error, surfaced as Fetch (not a panic).
348        let err = ModelRegistry::fetch("http://127.0.0.1:1/catalog.json").await;
349        assert!(matches!(err, Err(CostError::Fetch(_))));
350    }
351}