Skip to main content

rpi_cli/
remote_catalog.rs

1//! Remote model catalog overlay.
2//!
3//! Port of native Pi's `packages/coding-agent/src/core/remote-catalog-provider.ts`.
4//! A built-in provider ships a static model list; `pi.dev` publishes a
5//! per-provider catalog (`/api/models/providers/<id>`) that can add or refresh
6//! entries between releases. This module:
7//!
8//! * fetches that catalog with an `ETag`/`Last-Modified` validator,
9//! * caches the body + validator under the agent dir,
10//! * revalidates at most every [`REMOTE_CATALOG_REFRESH_INTERVAL_MS`] unless
11//!   forced, and
12//! * merges the overlay over the baseline (overlay entries win by id).
13
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16
17use rpi_ai::model::Model;
18use serde::{Deserialize, Serialize};
19
20/// Native `DEFAULT_CATALOG_BASE_URL`.
21pub const DEFAULT_CATALOG_BASE_URL: &str = "https://pi.dev";
22
23/// Native `REMOTE_CATALOG_REFRESH_INTERVAL_MS` (4 hours).
24pub const REMOTE_CATALOG_REFRESH_INTERVAL_MS: i64 = 4 * 60 * 60 * 1000;
25
26/// Persisted catalog entry for one provider (native `ModelsStoreEntry`).
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct CatalogEntry {
30    #[serde(default)]
31    pub models: Vec<Model>,
32    /// Wall-clock ms of the last successful (or 304) check.
33    #[serde(default)]
34    pub checked_at: i64,
35    /// `Last-Modified` of the fetched body (ms), or 0 when unknown.
36    #[serde(default)]
37    pub last_modified: i64,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub etag: Option<String>,
40}
41
42/// Persisted store: provider id → entry.
43#[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    /// Load the store; a missing or malformed file yields an empty store
56    /// (catalog caching is best-effort and must never block startup).
57    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
81/// Parse a catalog payload. Native `parseCatalog`: accepts a bare array,
82/// `{models:[…]}`, or an object keyed by id. Every entry is stamped with the
83/// requesting provider id.
84pub 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
111/// Overlay selection (native `remoteModels`): an entry only contributes when it
112/// is newer than the locally generated catalog, and a missing `last_modified`
113/// on the entry means "unknown" and is treated as stale.
114pub 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
126/// Merge overlay over baseline; overlay entries win by `id` (native `mergeModels`).
127pub 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/// Outcome of a refresh attempt.
139#[derive(Debug, Clone, PartialEq)]
140pub enum RefreshOutcome {
141    /// Network not allowed, or a fresh cache made the request unnecessary.
142    Skipped,
143    /// A `304 Not Modified` confirmed the cached overlay.
144    NotModified,
145    /// The catalog was refreshed (or explicitly absent: 404/501).
146    Updated(usize),
147}
148
149/// Fetch and cache the remote catalog for `provider_id`.
150///
151/// `allow_network == false` returns [`RefreshOutcome::Skipped`] without any I/O.
152/// `force` bypasses the freshness window. Returns the overlay models to merge.
153pub 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    // Serve the cached overlay first when the network is off or the cache is
164    // still fresh.
165    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            // Transient failure: keep the cached body + validator.
203            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
288/// Merge the remote overlay into a full catalog, provider by provider.
289///
290/// Best-effort: any provider whose refresh fails keeps its baseline entries.
291/// `allow_network == false` serves the persisted cache only (no request).
292pub 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
332/// Percent-encode a path segment (provider ids are simple, but be safe).
333fn 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
346/// Parse an HTTP date (`Last-Modified`) into ms since epoch. Best-effort: any
347/// format we don't understand yields 0 (treated as "unknown").
348fn parse_http_date_ms(value: &str) -> i64 {
349    // IMF-fixdate is the common case: "Sun, 06 Nov 1994 08:49:37 GMT".
350    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    // Days since epoch (Howard Hinnant's algorithm); `month` is 0-based, the
378    // algorithm expects 1-based March-anchored months.
379    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        // 1994-11-06T08:49:37Z == 784111777 s
454        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}