1use std::collections::BTreeMap;
15use std::path::PathBuf;
16
17use rpi_ai::model::Model;
18use serde::{Deserialize, Serialize};
19
20pub const DEFAULT_CATALOG_BASE_URL: &str = "https://pi.dev";
22
23pub const REMOTE_CATALOG_REFRESH_INTERVAL_MS: i64 = 4 * 60 * 60 * 1000;
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct CatalogEntry {
30 #[serde(default)]
31 pub models: Vec<Model>,
32 #[serde(default)]
34 pub checked_at: i64,
35 #[serde(default)]
37 pub last_modified: i64,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub etag: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct CatalogStore {
45 #[serde(flatten)]
46 pub providers: BTreeMap<String, CatalogEntry>,
47}
48
49impl CatalogStore {
50 pub fn path() -> Result<PathBuf, String> {
51 let dir = crate::config::agent_dir().map_err(|e| e.to_string())?;
52 Ok(dir.join("model-catalog.json"))
53 }
54
55 pub fn load() -> Self {
58 Self::load_from(&Self::path().ok())
59 }
60
61 pub fn load_from(path: &Option<PathBuf>) -> Self {
62 let Some(path) = path else {
63 return Self::default();
64 };
65 match std::fs::read_to_string(path) {
66 Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
67 Err(_) => Self::default(),
68 }
69 }
70
71 pub fn save(&self) -> Result<(), String> {
72 let path = Self::path()?;
73 if let Some(parent) = path.parent() {
74 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
75 }
76 let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
77 std::fs::write(&path, text).map_err(|e| e.to_string())
78 }
79}
80
81pub fn parse_catalog(provider_id: &str, value: &serde_json::Value) -> Result<Vec<Model>, String> {
85 let entries: Vec<serde_json::Value> = if let Some(array) = value.as_array() {
86 array.clone()
87 } else if let Some(models) = value.get("models").and_then(|m| m.as_array()) {
88 models.clone()
89 } else if let Some(object) = value.as_object() {
90 object.values().cloned().collect()
91 } else {
92 return Err(format!("Invalid model catalog for provider \"{provider_id}\""));
93 };
94
95 let mut out = Vec::with_capacity(entries.len());
96 for entry in entries {
97 if entry.get("id").is_none() {
98 continue;
99 }
100 match serde_json::from_value::<Model>(entry) {
101 Ok(mut model) => {
102 model.provider = rpi_ai::types::ProviderId::from(provider_id);
103 out.push(model);
104 }
105 Err(_) => continue,
106 }
107 }
108 Ok(out)
109}
110
111pub fn remote_models(entry: Option<&CatalogEntry>, local_generated_at: Option<i64>) -> Vec<Model> {
115 let Some(entry) = entry else {
116 return Vec::new();
117 };
118 if let Some(local) = local_generated_at {
119 if entry.last_modified == 0 || entry.last_modified <= local {
120 return Vec::new();
121 }
122 }
123 entry.models.clone()
124}
125
126pub fn merge_models(baseline: &[Model], dynamic: &[Model]) -> Vec<Model> {
128 let mut merged = baseline.to_vec();
129 for model in dynamic {
130 match merged.iter().position(|m| m.id == model.id) {
131 Some(index) => merged[index] = model.clone(),
132 None => merged.push(model.clone()),
133 }
134 }
135 merged
136}
137
138#[derive(Debug, Clone, PartialEq)]
140pub enum RefreshOutcome {
141 Skipped,
143 NotModified,
145 Updated(usize),
147}
148
149pub async fn refresh_provider_catalog(
154 provider_id: &str,
155 base_url: &str,
156 allow_network: bool,
157 force: bool,
158 local_generated_at: Option<i64>,
159 store: &mut CatalogStore,
160) -> Result<Vec<Model>, String> {
161 let stored = store.providers.get(provider_id).cloned();
162
163 if !allow_network {
166 return Ok(remote_models(stored.as_ref(), local_generated_at));
167 }
168 if !force {
169 if let Some(entry) = &stored {
170 let age = now_ms() - entry.checked_at;
171 if entry.checked_at > 0 && age >= 0 && age < REMOTE_CATALOG_REFRESH_INTERVAL_MS {
172 return Ok(remote_models(Some(entry), local_generated_at));
173 }
174 }
175 }
176
177 let url = format!(
178 "{}/api/models/providers/{}",
179 base_url.trim_end_matches('/'),
180 urlencode(provider_id)
181 );
182 let validator = stored
183 .as_ref()
184 .filter(|e| !e.models.is_empty())
185 .and_then(|e| e.etag.clone());
186
187 let client = reqwest::Client::builder()
188 .timeout(std::time::Duration::from_secs(15))
189 .build()
190 .map_err(|e| format!("catalog HTTP client: {e}"))?;
191 let mut request = client
192 .get(&url)
193 .header("accept", "application/json")
194 .header("User-Agent", format!("rpi/{}", crate::VERSION));
195 if let Some(etag) = &validator {
196 request = request.header("if-none-match", etag);
197 }
198
199 let response = match request.send().await {
200 Ok(r) => r,
201 Err(error) => {
202 if let Some(entry) = stored.clone() {
204 store.providers.insert(
205 provider_id.to_string(),
206 CatalogEntry {
207 checked_at: now_ms(),
208 ..entry
209 },
210 );
211 let _ = store.save();
212 }
213 return Err(format!("Model catalog request failed for {provider_id}: {error}"));
214 }
215 };
216
217 let status = response.status();
218 let checked_at = now_ms();
219
220 if status.as_u16() == 304 {
221 if let Some(mut entry) = stored.clone() {
222 entry.checked_at = checked_at;
223 store.providers.insert(provider_id.to_string(), entry.clone());
224 let _ = store.save();
225 return Ok(remote_models(Some(&entry), local_generated_at));
226 }
227 }
228
229 if status.as_u16() == 404 || status.as_u16() == 501 {
230 let mut entry = stored.unwrap_or_default();
231 entry.checked_at = checked_at;
232 entry.last_modified = 0;
233 entry.etag = None;
234 entry.models.clear();
235 store.providers.insert(provider_id.to_string(), entry);
236 let _ = store.save();
237 return Ok(Vec::new());
238 }
239
240 if !status.is_success() {
241 if let Some(entry) = stored {
242 store.providers.insert(
243 provider_id.to_string(),
244 CatalogEntry {
245 checked_at,
246 ..entry
247 },
248 );
249 let _ = store.save();
250 }
251 return Err(format!(
252 "Model catalog request failed for {provider_id}: {}",
253 status.as_u16()
254 ));
255 }
256
257 let last_modified = response
258 .headers()
259 .get("last-modified")
260 .and_then(|v| v.to_str().ok())
261 .map(parse_http_date_ms)
262 .unwrap_or(0);
263 let etag = response
264 .headers()
265 .get("etag")
266 .and_then(|v| v.to_str().ok())
267 .map(str::to_string);
268 let body: serde_json::Value = response
269 .json()
270 .await
271 .map_err(|e| format!("invalid catalog JSON for {provider_id}: {e}"))?;
272 let models = parse_catalog(provider_id, &body)?;
273
274 let entry = CatalogEntry {
275 models: models.clone(),
276 checked_at,
277 last_modified,
278 etag,
279 };
280 let overlay = remote_models(Some(&entry), local_generated_at);
281 let count = overlay.len();
282 store.providers.insert(provider_id.to_string(), entry);
283 let _ = store.save();
284 let _ = count;
285 Ok(overlay)
286}
287
288pub async fn merge_into_catalog(
293 baseline: Vec<Model>,
294 allow_network: bool,
295 local_generated_at: Option<i64>,
296) -> Vec<Model> {
297 use std::collections::BTreeSet;
298 let providers: BTreeSet<String> = baseline.iter().map(|m| m.provider.to_string()).collect();
299 if providers.is_empty() {
300 return baseline;
301 }
302 let mut store = CatalogStore::load();
303 let mut result = baseline;
304 for provider_id in providers {
305 let overlay = match refresh_provider_catalog(
306 &provider_id,
307 DEFAULT_CATALOG_BASE_URL,
308 allow_network,
309 false,
310 local_generated_at,
311 &mut store,
312 )
313 .await
314 {
315 Ok(models) => models,
316 Err(_) => Vec::new(),
317 };
318 if !overlay.is_empty() {
319 result = merge_models(&result, &overlay);
320 }
321 }
322 result
323}
324
325fn now_ms() -> i64 {
326 std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .map(|d| d.as_millis() as i64)
329 .unwrap_or(0)
330}
331
332fn urlencode(value: &str) -> String {
334 let mut out = String::with_capacity(value.len());
335 for byte in value.bytes() {
336 match byte {
337 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
338 out.push(byte as char)
339 }
340 other => out.push_str(&format!("%{other:02X}")),
341 }
342 }
343 out
344}
345
346fn parse_http_date_ms(value: &str) -> i64 {
349 const MONTHS: [&str; 12] = [
351 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
352 ];
353 let parts: Vec<&str> = value.split_whitespace().collect();
354 if parts.len() < 5 {
355 return 0;
356 }
357 let day: i64 = match parts[1].parse() {
358 Ok(d) => d,
359 Err(_) => return 0,
360 };
361 let month = match MONTHS.iter().position(|m| *m == parts[2]) {
362 Some(m) => m as i64,
363 None => return 0,
364 };
365 let year: i64 = match parts[3].parse() {
366 Ok(y) => y,
367 Err(_) => return 0,
368 };
369 let time: Vec<&str> = parts[4].split(':').collect();
370 if time.len() != 3 {
371 return 0;
372 }
373 let (h, m, s): (i64, i64, i64) = match (time[0].parse(), time[1].parse(), time[2].parse()) {
374 (Ok(h), Ok(m), Ok(s)) => (h, m, s),
375 _ => return 0,
376 };
377 let month1 = month + 1;
380 let y = if month1 <= 2 { year - 1 } else { year };
381 let era = if y >= 0 { y } else { y - 399 } / 400;
382 let yoe = y - era * 400;
383 let mp = (month1 + 9) % 12;
384 let doy = (153 * mp + 2) / 5 + day - 1;
385 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
386 let days = era * 146097 + doe - 719468;
387 (days * 86_400 + h * 3600 + m * 60 + s) * 1000
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use rpi_ai::types::{Api, ModelCost, ProviderId};
394
395 fn model(id: &str, provider: &str) -> Model {
396 Model {
397 id: id.to_string(),
398 name: id.to_string(),
399 api: Api::AnthropicMessages,
400 provider: ProviderId::from(provider),
401 base_url: String::new(),
402 reasoning: false,
403 thinking_level_map: None,
404 input: vec![],
405 cost: ModelCost::default(),
406 context_window: 0,
407 max_tokens: 0,
408 sampling_params: None,
409 headers: None,
410 compat: None,
411 }
412 }
413
414 #[test]
415 fn parse_array_and_object() {
416 let array = serde_json::json!([{ "id": "m1", "name": "M1", "api": "anthropic-messages",
417 "provider": "x", "baseUrl": "", "reasoning": false, "input": [],
418 "cost": { "input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0 },
419 "contextWindow": 0, "maxTokens": 0 }]);
420 let parsed = parse_catalog("anthropic", &array).unwrap();
421 assert_eq!(parsed.len(), 1);
422 assert_eq!(parsed[0].provider.to_string(), "anthropic");
423
424 let wrapped = serde_json::json!({ "models": array });
425 assert_eq!(parse_catalog("anthropic", &wrapped).unwrap().len(), 1);
426 }
427
428 #[test]
429 fn merge_overlay_wins_by_id() {
430 let base = vec![model("a", "p"), model("b", "p")];
431 let overlay = vec![model("b", "p"), model("c", "p")];
432 let merged = merge_models(&base, &overlay);
433 assert_eq!(merged.len(), 3);
434 assert_eq!(merged.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), vec!["a", "b", "c"]);
435 }
436
437 #[test]
438 fn remote_models_respects_local_generation() {
439 let entry = CatalogEntry {
440 models: vec![model("a", "p")],
441 checked_at: 0,
442 last_modified: 1_000,
443 etag: None,
444 };
445 assert!(remote_models(Some(&entry), Some(2_000)).is_empty());
446 assert_eq!(remote_models(Some(&entry), Some(500)).len(), 1);
447 assert_eq!(remote_models(Some(&entry), None).len(), 1);
448 assert!(remote_models(None, None).is_empty());
449 }
450
451 #[test]
452 fn http_date_parses_imf_fixdate() {
453 assert_eq!(parse_http_date_ms("Sun, 06 Nov 1994 08:49:37 GMT"), 784_111_777_000);
455 assert_eq!(parse_http_date_ms("not a date"), 0);
456 }
457}