Skip to main content

yapi_ai/
model_catalog.rs

1//! Model catalogs that change after a release: pi.dev's catalog overlay for
2//! built-in providers, and the catalogs of Radius gateways. Refreshed
3//! catalogs persist in `models-store.json`, so later startups have them
4//! offline.
5//!
6//! Ports of `remote-catalog-provider.ts` and `models-store.ts` in
7//! `pi-coding-agent`, and of the Radius provider's `refreshModels` in pi-ai
8//! `v1.0.0`.
9
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use indexmap::IndexMap;
14use serde_json::{Map, Value, json};
15use tokio_util::sync::CancellationToken;
16use yapi_types::model::{ClassifierModel, ImageModel, Model};
17
18use crate::auth::lock::{FileLock, write_atomic};
19use crate::auth::now_ms;
20
21/// pi.dev, where pi publishes catalog updates between releases.
22pub const DEFAULT_CATALOG_BASE_URL: &str = "https://pi.dev";
23/// A catalog checked this recently is not fetched again unless forced.
24pub const REFRESH_INTERVAL_MS: u64 = 4 * 60 * 60 * 1000;
25/// Model types this client asks the catalog for.
26const MODEL_TYPES: &str = "chat,image,classifier";
27const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(4);
28const RETRYABLE: [u16; 6] = [408, 429, 500, 502, 503, 504];
29
30/// pi's `FileModelsStore`: catalogs by provider id in `models-store.json`.
31#[derive(Clone, Debug)]
32pub struct ModelsStore {
33    path: PathBuf,
34}
35
36impl ModelsStore {
37    /// The store at `path`.
38    pub fn new(path: impl Into<PathBuf>) -> ModelsStore {
39        ModelsStore { path: path.into() }
40    }
41
42    /// The store file.
43    pub fn path(&self) -> &Path {
44        &self.path
45    }
46
47    /// Every stored entry. An unreadable file reads as empty.
48    pub fn read_all(&self) -> Map<String, Value> {
49        std::fs::read_to_string(&self.path)
50            .ok()
51            .and_then(|text| {
52                serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok()
53            })
54            .and_then(|value| value.as_object().cloned())
55            .unwrap_or_default()
56    }
57
58    /// One provider's entry.
59    pub fn read(&self, provider: &str) -> Option<Value> {
60        self.read_all().remove(provider)
61    }
62
63    /// Replaces `provider`'s entry under the file lock, as pi writes it.
64    pub async fn write(
65        &self,
66        provider: &str,
67        entry: Value,
68        cancel: &CancellationToken,
69    ) -> Result<(), String> {
70        self.modify(provider, Some(entry), cancel).await
71    }
72
73    /// Removes `provider`'s entry under the file lock.
74    pub async fn delete(&self, provider: &str, cancel: &CancellationToken) -> Result<(), String> {
75        self.modify(provider, None, cancel).await
76    }
77
78    async fn modify(
79        &self,
80        provider: &str,
81        entry: Option<Value>,
82        cancel: &CancellationToken,
83    ) -> Result<(), String> {
84        if let Some(dir) = self.path.parent() {
85            std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
86        }
87        if !self.path.exists() {
88            std::fs::write(&self.path, "").map_err(|err| err.to_string())?;
89        }
90        let _lock = FileLock::acquire(&self.path, cancel)
91            .await
92            .map_err(|err| err.to_string())?;
93        let mut all = self.read_all();
94        match entry {
95            Some(entry) => all.insert(provider.to_owned(), entry),
96            None => all.remove(provider),
97        };
98        let text = yapi_types::json::to_string_pretty(&Value::Object(all), "  ")
99            .map_err(|err| err.to_string())?;
100        write_atomic(&self.path, text.as_bytes(), 0o666).map_err(|err| err.to_string())
101    }
102}
103
104/// How a provider's catalog refreshes.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub enum Source {
107    /// pi.dev's overlay on a built-in catalog.
108    Remote,
109    /// A Radius gateway's own catalog.
110    Radius {
111        /// The gateway origin.
112        gateway: String,
113    },
114    /// The models a llama.cpp server serves.
115    Llama {
116        /// The server URL.
117        server: String,
118    },
119}
120
121/// A provider's models of every type.
122#[derive(Clone, Debug, Default, PartialEq)]
123pub struct ProviderModels {
124    /// Chat models.
125    pub chat: Vec<Model>,
126    /// Classifier models.
127    pub classifiers: Vec<ClassifierModel>,
128    /// Image-generation models.
129    pub images: Vec<ImageModel>,
130}
131
132impl ProviderModels {
133    /// Only chat models.
134    pub fn chat(chat: Vec<Model>) -> ProviderModels {
135        ProviderModels {
136            chat,
137            ..ProviderModels::default()
138        }
139    }
140
141    /// Whether there are no models of any type.
142    pub fn is_empty(&self) -> bool {
143        self.chat.is_empty() && self.classifiers.is_empty() && self.images.is_empty()
144    }
145}
146
147/// The models of a stored or fetched catalog, for `provider`, by type.
148/// Models of unknown types, and invalid ones, are dropped.
149fn typed_models(provider: &str, models: &Value) -> ProviderModels {
150    let mut typed = ProviderModels::default();
151    for model in models.as_array().into_iter().flatten() {
152        if model["provider"].as_str().is_some_and(|p| p != provider) {
153            continue;
154        }
155        let mut model = model.clone();
156        model["provider"] = json!(provider);
157        match model["type"].as_str().unwrap_or("chat") {
158            "chat" => typed.chat.extend(serde_json::from_value(model).ok()),
159            "classifier" => typed.classifiers.extend(serde_json::from_value(model).ok()),
160            "image" => typed.images.extend(serde_json::from_value(model).ok()),
161            _ => {}
162        }
163    }
164    typed
165}
166
167/// The models a stored entry contributes: a Radius catalog as stored, and
168/// pi.dev's overlay only when it is newer than the built-in catalog.
169pub fn restore(provider: &str, source: &Source, entry: &Value) -> ProviderModels {
170    match source {
171        Source::Radius { .. } | Source::Llama { .. } => typed_models(provider, &entry["models"]),
172        Source::Remote => {
173            let newer = entry["lastModified"]
174                .as_u64()
175                .is_some_and(|modified| modified > generated_at_ms());
176            if newer {
177                typed_models(provider, &entry["models"])
178            } else {
179                ProviderModels::default()
180            }
181        }
182    }
183}
184
185/// When the built-in catalog was generated.
186pub fn generated_at_ms() -> u64 {
187    yapi_types::time::parse_iso(crate::catalog::GENERATED_AT).unwrap_or(0)
188}
189
190/// How long pi lets a catalog refresh run before aborting it.
191pub const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
192
193/// A token cancelled after `timeout`, as `AbortSignal.timeout` is. Needs a
194/// tokio runtime.
195pub fn cancel_after(timeout: std::time::Duration) -> CancellationToken {
196    let cancel = CancellationToken::new();
197    let timer = cancel.clone();
198    tokio::spawn(async move {
199        tokio::time::sleep(timeout).await;
200        timer.cancel();
201    });
202    cancel
203}
204
205/// What a refresh does.
206#[derive(Clone, Debug)]
207pub struct RefreshOptions {
208    /// Only these providers, or all refreshable ones when `None`.
209    pub providers: Option<Vec<String>>,
210    /// Whether to fetch. Stored catalogs are restored either way.
211    pub allow_network: bool,
212    /// Fetch even when the stored catalog is fresh.
213    pub force: bool,
214    /// Where pi.dev's catalogs live.
215    pub catalog_base_url: String,
216    /// Stops the refresh.
217    pub cancel: CancellationToken,
218}
219
220impl Default for RefreshOptions {
221    fn default() -> RefreshOptions {
222        RefreshOptions {
223            providers: None,
224            allow_network: true,
225            force: false,
226            catalog_base_url: DEFAULT_CATALOG_BASE_URL.to_owned(),
227            cancel: CancellationToken::new(),
228        }
229    }
230}
231
232/// One provider to refresh, with what the refresh needs from the registry.
233#[derive(Clone, Debug)]
234pub struct Target {
235    /// Provider id.
236    pub provider: String,
237    /// Where its catalog comes from.
238    pub source: Source,
239    /// Its credential for the gateway, when it has one: an OAuth access token
240    /// or an API key. pi.dev's catalogs need none but refresh only for
241    /// configured providers.
242    pub token: Option<String>,
243    /// Whether the provider is configured.
244    pub configured: bool,
245}
246
247/// What a refresh found.
248#[derive(Debug, Default)]
249pub struct Refreshed {
250    /// Models each provider's catalog contributes now.
251    pub models: IndexMap<String, ProviderModels>,
252    /// Providers whose refresh failed, with why, in target order.
253    pub errors: Vec<(String, String)>,
254    /// Whether the refresh was cancelled before it finished.
255    pub aborted: bool,
256}
257
258async fn get(
259    url: &str,
260    headers: &[(&str, String)],
261    cancel: &CancellationToken,
262) -> Result<reqwest::Response, String> {
263    let mut attempt = 0;
264    loop {
265        let mut request = crate::http::client().get(url).timeout(ATTEMPT_TIMEOUT);
266        for (name, value) in headers {
267            request = request.header(*name, value.as_str());
268        }
269        let Some(result) = cancel.run_until_cancelled(request.send()).await else {
270            return Err(crate::http::ABORTED_BEFORE_RESPONSE.into());
271        };
272        match result {
273            Ok(response) if RETRYABLE.contains(&response.status().as_u16()) && attempt < 2 => {}
274            Ok(response) => return Ok(response),
275            Err(_) if attempt < 2 => {}
276            Err(err) => return Err(crate::auth::network_message(&err)),
277        }
278        attempt += 1;
279        tokio::time::sleep(Duration::from_millis(250 << attempt)).await;
280    }
281}
282
283/// pi's `parseCatalog`: an array, `{models: [...]}`, or an object of models.
284fn parse_catalog(provider: &str, value: Value) -> Result<Vec<Value>, String> {
285    let entries: Vec<Value> = match value {
286        Value::Array(items) => items,
287        Value::Object(mut object) => match object.remove("models") {
288            Some(Value::Array(items)) => items,
289            Some(models) => {
290                object.insert("models".into(), models);
291                object.into_iter().map(|(_, value)| value).collect()
292            }
293            None => object.into_iter().map(|(_, value)| value).collect(),
294        },
295        _ => return Err(format!("Invalid model catalog for provider \"{provider}\"")),
296    };
297    Ok(entries
298        .into_iter()
299        .filter(|entry| entry.is_object() && entry.get("id").is_some())
300        .filter(|entry| {
301            entry["type"]
302                .as_str()
303                .is_none_or(|kind| matches!(kind, "chat" | "image" | "classifier"))
304                && (entry["type"].is_null() || entry["type"].is_string())
305        })
306        .map(|mut entry| {
307            entry["provider"] = json!(provider);
308            entry
309        })
310        .collect())
311}
312
313/// Milliseconds since the epoch of an HTTP date.
314pub(crate) fn http_date_ms(text: &str) -> Option<u64> {
315    // RFC 7231 IMF-fixdate: `Sun, 06 Nov 1994 08:49:37 GMT`.
316    let mut parts = text.split_whitespace().skip(1);
317    let day: i64 = parts.next()?.parse().ok()?;
318    const MONTHS: [&str; 12] = [
319        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
320    ];
321    let month = parts.next()?;
322    let month = MONTHS.iter().position(|name| *name == month)? + 1;
323    let year: i64 = parts.next()?.parse().ok()?;
324    let time = parts.next()?;
325    yapi_types::time::parse_iso(&format!("{year:04}-{month:02}-{day:02}T{time}Z"))
326}
327
328async fn refresh_remote(
329    target: &Target,
330    stored: Option<&Value>,
331    store: &ModelsStore,
332    options: &RefreshOptions,
333) -> Result<Option<ProviderModels>, String> {
334    let provider = &target.provider;
335    let checked = stored.and_then(|entry| entry["checkedAt"].as_u64());
336    let modified = stored.and_then(|entry| entry["lastModified"].as_u64());
337    if !options.force
338        && let (Some(checked), Some(_)) = (checked, modified)
339        && now_ms().saturating_sub(checked) < REFRESH_INTERVAL_MS
340    {
341        return Ok(None);
342    }
343    // Revalidate only when a cached body backs the validator.
344    let validator = stored
345        .filter(|entry| {
346            entry["models"]
347                .as_array()
348                .is_some_and(|models| !models.is_empty())
349        })
350        .and_then(|entry| entry["etag"].as_str().map(str::to_owned));
351    let mut url = url::Url::parse(&options.catalog_base_url).map_err(|err| err.to_string())?;
352    url.set_path(&format!(
353        "/api/models/providers/{}",
354        crate::aws::sigv4::escape(provider)
355    ));
356    url.query_pairs_mut().append_pair("types", MODEL_TYPES);
357    let mut headers = vec![("accept", "application/json".to_owned())];
358    if let Some(validator) = validator {
359        headers.push(("if-none-match", validator));
360    }
361    let response = get(url.as_str(), &headers, &options.cancel).await?;
362    let checked_at = now_ms();
363    let status = response.status().as_u16();
364    let mut entry = stored.cloned().unwrap_or_else(|| json!({ "models": [] }));
365    if status == 304 && stored.is_some() {
366        entry["checkedAt"] = json!(checked_at);
367        store.write(provider, entry, &options.cancel).await?;
368        return Ok(None);
369    }
370    if status == 404 || status == 501 {
371        entry["checkedAt"] = json!(checked_at);
372        entry["lastModified"] = json!(0);
373        if let Some(object) = entry.as_object_mut() {
374            object.remove("etag");
375        }
376        store.write(provider, entry, &options.cancel).await?;
377        return Ok(Some(ProviderModels::default()));
378    }
379    if !(200..300).contains(&status) {
380        entry["checkedAt"] = json!(checked_at);
381        store.write(provider, entry, &options.cancel).await?;
382        return Err(format!(
383            "Model catalog request failed for {provider}: {status}"
384        ));
385    }
386    let last_modified = response
387        .headers()
388        .get("last-modified")
389        .and_then(|value| value.to_str().ok())
390        .and_then(http_date_ms)
391        .unwrap_or(0);
392    let etag = response
393        .headers()
394        .get("etag")
395        .and_then(|value| value.to_str().ok())
396        .map(str::to_owned);
397    let bytes = response.bytes().await.map_err(|err| err.to_string())?;
398    let body: Value = serde_json::from_slice(&bytes)
399        .map_err(|err| format!("Invalid model catalog for provider \"{provider}\": {err}"))?;
400    let models = parse_catalog(provider, body)?;
401    let mut entry = json!({
402        "models": models,
403        "checkedAt": checked_at,
404        "lastModified": last_modified,
405    });
406    if let Some(etag) = etag {
407        entry["etag"] = json!(etag);
408    }
409    store
410        .write(provider, entry.clone(), &options.cancel)
411        .await?;
412    Ok(Some(restore(provider, &Source::Remote, &entry)))
413}
414
415/// pi's llama.cpp `refreshModels`: the server's loaded models, keeping the
416/// context windows learned before for models that no longer report one.
417async fn refresh_llama(
418    target: &Target,
419    server: &str,
420    stored: Option<&Value>,
421    store: &ModelsStore,
422    options: &RefreshOptions,
423) -> Result<Option<ProviderModels>, String> {
424    let client = crate::llama::Client::new(server, target.token.clone())?;
425    let cached: Map<String, Value> = stored
426        .and_then(|entry| entry["models"].as_array())
427        .into_iter()
428        .flatten()
429        .filter_map(|model| {
430            Some((
431                model["id"].as_str()?.to_owned(),
432                model["contextWindow"].clone(),
433            ))
434        })
435        .collect();
436    let models = crate::llama::fetch_models(&client, &cached, &options.cancel).await?;
437    if options.cancel.is_cancelled() {
438        return Ok(None);
439    }
440    let mut list: Vec<Value> = models
441        .chat
442        .iter()
443        .filter_map(|model| serde_json::to_value(model).ok())
444        .collect();
445    list.extend(
446        models
447            .classifiers
448            .iter()
449            .filter_map(|model| serde_json::to_value(model).ok()),
450    );
451    let entry = json!({ "models": list, "checkedAt": now_ms() });
452    store
453        .write(&target.provider, entry, &options.cancel)
454        .await?;
455    Ok(Some(models))
456}
457
458/// pi's `loadRadiusGatewayConfig` and `getRadiusModelsFromConfig`.
459async fn refresh_radius(
460    target: &Target,
461    gateway: &str,
462    store: &ModelsStore,
463    options: &RefreshOptions,
464) -> Result<Option<ProviderModels>, String> {
465    let mut headers = vec![("accept", "application/json".to_owned())];
466    if let Some(token) = &target.token {
467        headers.push(("authorization", format!("Bearer {token}")));
468    }
469    let response = get(&format!("{gateway}/v1/config"), &headers, &options.cancel).await?;
470    let status = response.status().as_u16();
471    if !(200..300).contains(&status) {
472        let text = response.text().await.unwrap_or_default();
473        let trimmed = text.trim();
474        let body = if trimmed.chars().count() > 512 {
475            format!("{}…", trimmed.chars().take(512).collect::<String>())
476        } else {
477            trimmed.to_owned()
478        };
479        return Err(format!(
480            "Could not load Radius config from {gateway}: {status}: {body}"
481        ));
482    }
483    let config = crate::auth::json_body(response).await;
484    let (Some(base_url), Some(models)) = (config["baseUrl"].as_str(), config["models"].as_array())
485    else {
486        return Err(format!("Invalid Radius config from {gateway}"));
487    };
488    let valid = |model: &&Value| {
489        model["id"].is_string()
490            && model["name"].is_string()
491            && model["reasoning"].is_boolean()
492            && model["input"].is_array()
493            && model["cost"].is_object()
494            && model["contextWindow"].is_number()
495            && model["maxTokens"].is_number()
496    };
497    let models: Vec<Value> = models
498        .iter()
499        .filter(valid)
500        .map(|model| {
501            let mut model = model.clone();
502            model["api"] = json!("pi-messages");
503            model["provider"] = json!(target.provider);
504            model["baseUrl"] = json!(base_url);
505            model
506        })
507        .collect();
508    let entry = json!({ "models": models, "checkedAt": now_ms() });
509    store
510        .write(&target.provider, entry.clone(), &options.cancel)
511        .await?;
512    Ok(Some(typed_models(&target.provider, &entry["models"])))
513}
514
515/// One provider's refresh: its models, if it has a catalog, and why fetching
516/// failed, if it did.
517async fn refresh_one(
518    target: &Target,
519    entry: Option<&Value>,
520    store: &ModelsStore,
521    options: &RefreshOptions,
522) -> (Option<ProviderModels>, Option<String>) {
523    let restored = entry.map(|entry| restore(&target.provider, &target.source, entry));
524    if !options.allow_network || options.cancel.is_cancelled() || !target.configured {
525        return (restored, None);
526    }
527    let result = match &target.source {
528        Source::Remote => refresh_remote(target, entry, store, options).await,
529        Source::Radius { gateway } => refresh_radius(target, gateway, store, options).await,
530        Source::Llama { server } => refresh_llama(target, server, entry, store, options).await,
531    };
532    match result {
533        Ok(Some(models)) => (Some(models), None),
534        Ok(None) => (restored, None),
535        Err(_) if options.cancel.is_cancelled() => (restored, None),
536        Err(error) => (restored, Some(error)),
537    }
538}
539
540/// Refreshes `targets` concurrently: restores each stored catalog, then
541/// fetches the configured providers' catalogs when the network is allowed.
542pub async fn refresh(
543    targets: &[Target],
544    store: &ModelsStore,
545    options: &RefreshOptions,
546) -> Refreshed {
547    let selected = |provider: &str| {
548        options
549            .providers
550            .as_ref()
551            .is_none_or(|providers| providers.iter().any(|p| p == provider))
552    };
553    let stored = store.read_all();
554    let chosen: Vec<&Target> = targets
555        .iter()
556        .filter(|target| selected(&target.provider))
557        .collect();
558    let results = futures_util::future::join_all(
559        chosen
560            .iter()
561            .map(|target| refresh_one(target, stored.get(&target.provider), store, options)),
562    )
563    .await;
564    let mut refreshed = Refreshed {
565        aborted: options.cancel.is_cancelled(),
566        ..Refreshed::default()
567    };
568    for (target, (models, error)) in chosen.into_iter().zip(results) {
569        if let Some(models) = models {
570            refreshed.models.insert(target.provider.clone(), models);
571        }
572        if let Some(error) = error {
573            refreshed.errors.push((target.provider.clone(), error));
574        }
575    }
576    refreshed
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[tokio::test]
584    async fn store_writes_and_deletes() {
585        let dir = std::env::temp_dir().join(format!("yapi-models-store-{}", std::process::id()));
586        let store = ModelsStore::new(dir.join("models-store.json"));
587        let cancel = CancellationToken::new();
588        store
589            .write("a", json!({"models": []}), &cancel)
590            .await
591            .unwrap();
592        store
593            .write("b", json!({"models": [1]}), &cancel)
594            .await
595            .unwrap();
596        store.delete("a", &cancel).await.unwrap();
597        assert_eq!(store.read("a"), None);
598        assert_eq!(store.read("b"), Some(json!({"models": [1]})));
599        std::fs::remove_dir_all(&dir).unwrap();
600    }
601
602    #[test]
603    fn parses_shapes_and_dates() {
604        let models = parse_catalog(
605            "xai",
606            json!({"models": [{"id": "a"}, {"id": "b", "type": "video"}, {"name": "no id"}]}),
607        )
608        .unwrap();
609        assert_eq!(models.len(), 1);
610        assert_eq!(models[0]["provider"], "xai");
611        assert_eq!(
612            parse_catalog("xai", json!({"a": {"id": "a"}}))
613                .unwrap()
614                .len(),
615            1
616        );
617        assert!(parse_catalog("xai", json!(1)).is_err());
618        assert_eq!(http_date_ms("Thu, 01 Jan 1970 00:00:10 GMT"), Some(10_000));
619        assert!(generated_at_ms() > 0);
620    }
621}