1use 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
21pub const DEFAULT_CATALOG_BASE_URL: &str = "https://pi.dev";
23pub const REFRESH_INTERVAL_MS: u64 = 4 * 60 * 60 * 1000;
25const 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#[derive(Clone, Debug)]
32pub struct ModelsStore {
33 path: PathBuf,
34}
35
36impl ModelsStore {
37 pub fn new(path: impl Into<PathBuf>) -> ModelsStore {
39 ModelsStore { path: path.into() }
40 }
41
42 pub fn path(&self) -> &Path {
44 &self.path
45 }
46
47 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 pub fn read(&self, provider: &str) -> Option<Value> {
60 self.read_all().remove(provider)
61 }
62
63 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 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#[derive(Clone, Debug, PartialEq, Eq)]
106pub enum Source {
107 Remote,
109 Radius {
111 gateway: String,
113 },
114 Llama {
116 server: String,
118 },
119}
120
121#[derive(Clone, Debug, Default, PartialEq)]
123pub struct ProviderModels {
124 pub chat: Vec<Model>,
126 pub classifiers: Vec<ClassifierModel>,
128 pub images: Vec<ImageModel>,
130}
131
132impl ProviderModels {
133 pub fn chat(chat: Vec<Model>) -> ProviderModels {
135 ProviderModels {
136 chat,
137 ..ProviderModels::default()
138 }
139 }
140
141 pub fn is_empty(&self) -> bool {
143 self.chat.is_empty() && self.classifiers.is_empty() && self.images.is_empty()
144 }
145}
146
147fn 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
167pub 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
185pub fn generated_at_ms() -> u64 {
187 yapi_types::time::parse_iso(crate::catalog::GENERATED_AT).unwrap_or(0)
188}
189
190pub const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
192
193pub 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#[derive(Clone, Debug)]
207pub struct RefreshOptions {
208 pub providers: Option<Vec<String>>,
210 pub allow_network: bool,
212 pub force: bool,
214 pub catalog_base_url: String,
216 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#[derive(Clone, Debug)]
234pub struct Target {
235 pub provider: String,
237 pub source: Source,
239 pub token: Option<String>,
243 pub configured: bool,
245}
246
247#[derive(Debug, Default)]
249pub struct Refreshed {
250 pub models: IndexMap<String, ProviderModels>,
252 pub errors: Vec<(String, String)>,
254 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
283fn 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
313pub(crate) fn http_date_ms(text: &str) -> Option<u64> {
315 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 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
415async 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
458async 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
515async 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
540pub 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}