1use std::collections::HashMap;
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38
39use crate::cost::{CostError, ModelPrice};
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ModelCapabilities {
47 #[serde(default)]
49 pub tools: bool,
50 #[serde(default)]
52 pub vision: bool,
53 #[serde(default)]
55 pub json_mode: bool,
56 #[serde(default)]
58 pub reasoning: bool,
59 #[serde(default)]
61 pub audio: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ModelInfo {
67 pub provider: String,
69 pub id: String,
71 pub context_window: usize,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub max_output_tokens: Option<usize>,
76 #[serde(default)]
78 pub capabilities: ModelCapabilities,
79 pub price: ModelPrice,
81}
82
83impl ModelInfo {
84 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 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 pub fn with_capabilities(mut self, capabilities: ModelCapabilities) -> Self {
109 self.capabilities = capabilities;
110 self
111 }
112
113 pub fn key(&self) -> String {
115 format!("{}/{}", self.provider, self.id)
116 }
117}
118
119#[derive(Debug, Clone, Deserialize)]
121struct CatalogEnvelope {
122 #[serde(default)]
123 #[allow(dead_code)]
124 version: Option<u32>,
125 models: Vec<ModelInfo>,
126}
127
128#[derive(Debug, Clone, Default)]
130pub struct ModelRegistry {
131 models: HashMap<String, Arc<ModelInfo>>,
132}
133
134impl ModelRegistry {
135 pub fn new() -> Self {
137 Self::default()
138 }
139
140 pub fn register(&mut self, info: ModelInfo) {
142 self.models.insert(info.key(), Arc::new(info));
143 }
144
145 pub fn with(mut self, info: ModelInfo) -> Self {
147 self.register(info);
148 self
149 }
150
151 pub fn merge(&mut self, other: ModelRegistry) {
154 for (key, info) in other.models {
155 self.models.insert(key, info);
156 }
157 }
158
159 pub fn get(&self, provider: &str, model: &str) -> Option<&Arc<ModelInfo>> {
161 self.models.get(&format!("{provider}/{model}"))
162 }
163
164 pub fn get_by_key(&self, key: &str) -> Option<&Arc<ModelInfo>> {
166 self.models.get(key)
167 }
168
169 pub fn models(&self) -> impl Iterator<Item = &Arc<ModelInfo>> {
171 self.models.values()
172 }
173
174 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 pub fn len(&self) -> usize {
184 self.models.len()
185 }
186
187 pub fn is_empty(&self) -> bool {
189 self.models.is_empty()
190 }
191
192 pub fn from_json(json: &str) -> Result<Self, CostError> {
195 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 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 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 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 let err = ModelRegistry::fetch("http://127.0.0.1:1/catalog.json").await;
349 assert!(matches!(err, Err(CostError::Fetch(_))));
350 }
351}