1use std::collections::BTreeMap;
12use std::future::Future;
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::pin::Pin;
16use std::sync::Arc;
17use std::time::{Duration, SystemTime};
18
19use parking_lot::RwLock;
20use serde::{Deserialize, Serialize};
21use tokio::sync::broadcast;
22
23use crate::error::SdkResult;
24use crate::ports::catalog::{
25 CatalogEvent, CatalogModelEntry, CatalogProtocol, CatalogProviderEntry, CatalogSource,
26 ModelCatalog, RefreshOutcome,
27};
28
29const DEFAULT_MTIME_WINDOW: Duration = Duration::from_secs(60 * 60);
34const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
35const FETCH_RETRIES: u32 = 2;
36const RETRY_BACKOFF: Duration = Duration::from_millis(200);
37const DEFAULT_URL: &str = "https://models.dev";
38const USER_AGENT: &str = concat!("oxicode-sdk/", env!("CARGO_PKG_VERSION"));
39const BROADCAST_CAPACITY: usize = 16;
40
41#[derive(Debug, Clone)]
50pub struct CatalogConfig {
51 pub cache_path: PathBuf,
53 pub etag_path: PathBuf,
55 pub override_path: PathBuf,
57 pub mtime_window: Duration,
59 pub fetch_enabled: bool,
61 pub models_dev_url: String,
63 pub user_agent: String,
65 pub local_discovery_urls: Vec<String>,
68 pub snapshot_path: PathBuf,
71}
72
73impl Default for CatalogConfig {
74 fn default() -> Self {
75 let home = crate::ports::fs::path::home_dir().unwrap_or_else(|_| PathBuf::from(".oxicode"));
76 let cache = home.join("cache");
77 let catalog_dir = home.join("catalog");
78 Self {
79 cache_path: cache.join("models-dev.json"),
80 etag_path: cache.join("models-dev.json.etag"),
81 override_path: catalog_dir.join("overrides.toml"),
82 mtime_window: DEFAULT_MTIME_WINDOW,
83 fetch_enabled: true,
84 models_dev_url: DEFAULT_URL.to_string(),
85 user_agent: USER_AGENT.to_string(),
86 local_discovery_urls: Vec::new(),
87 snapshot_path: home.join("cache").join("models-dev.json"),
88 }
89 }
90}
91
92#[derive(Debug, Default, Serialize, Deserialize)]
97pub(crate) struct MdCatalog(pub BTreeMap<String, MdProvider>);
98
99#[derive(Debug, Serialize, Deserialize)]
100pub(crate) struct MdProvider {
101 pub name: String,
102 pub env: Vec<String>,
103 #[serde(default)]
104 pub npm: Option<String>,
105 #[serde(default)]
106 pub api: Option<String>,
107 #[serde(default)]
108 pub doc: Option<String>,
109 pub models: BTreeMap<String, MdModel>,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
113pub(crate) struct MdModel {
114 pub name: String,
115 #[serde(default)]
116 pub family: Option<String>,
117 pub reasoning: bool,
118 #[serde(default)]
119 pub tool_call: bool,
120 #[serde(default)]
121 pub attachment: bool,
122 #[serde(default)]
123 pub temperature: Option<bool>,
124 #[serde(default)]
125 pub structured_output: Option<bool>,
126 #[serde(default)]
127 pub knowledge: Option<String>,
128 #[serde(default)]
129 pub release_date: Option<String>,
130 #[serde(default)]
131 pub last_updated: Option<String>,
132 #[serde(default)]
133 pub open_weights: Option<bool>,
134 #[serde(default)]
135 pub interleaved: Option<serde_json::Value>,
136 #[serde(default)]
137 pub reasoning_options: Option<Vec<serde_json::Value>>,
138 pub limit: MdLimit,
139 #[serde(default)]
140 pub cost: Option<MdCost>,
141 #[serde(default)]
142 pub modalities: Option<MdModalities>,
143 #[serde(default)]
144 pub status: Option<String>,
145 #[serde(default)]
146 pub provider: Option<MdModelProvider>,
147}
148
149#[derive(Debug, Serialize, Deserialize)]
150pub(crate) struct MdModelProvider {
151 #[serde(default)]
152 pub npm: Option<String>,
153 #[serde(default)]
154 pub api: Option<String>,
155}
156
157#[derive(Debug, Serialize, Deserialize)]
158pub(crate) struct MdLimit {
159 pub context: f64,
160 #[serde(default)]
161 pub input: Option<f64>,
162 pub output: f64,
163}
164
165#[derive(Debug, Serialize, Deserialize)]
166pub(crate) struct MdCost {
167 pub input: f64,
168 pub output: f64,
169 #[serde(default)]
170 pub cache_read: Option<f64>,
171 #[serde(default)]
172 pub cache_write: Option<f64>,
173 #[serde(default)]
174 pub tiers: Option<Vec<serde_json::Value>>,
175 #[serde(default)]
176 pub context_over_200k: Option<serde_json::Value>,
177 #[serde(default)]
178 pub reasoning: Option<f64>,
179 #[serde(default)]
180 pub input_audio: Option<f64>,
181 #[serde(default)]
182 pub output_audio: Option<f64>,
183}
184
185#[derive(Debug, Serialize, Deserialize)]
186pub(crate) struct MdModalities {
187 #[serde(default)]
188 pub input: Option<Vec<String>>,
189 #[serde(default)]
190 pub output: Option<Vec<String>>,
191}
192
193#[derive(Debug, Default, Serialize, Deserialize)]
198pub(crate) struct OverrideFile {
199 #[serde(default)]
200 pub provider: Vec<OverrideProvider>,
201 #[serde(default)]
202 pub model: Vec<OverrideModel>,
203}
204
205#[derive(Debug, Serialize, Deserialize)]
206pub(crate) struct OverrideProvider {
207 pub id: String,
208 #[serde(default)]
209 pub display_name: Option<String>,
210 #[serde(default)]
211 pub base_url: Option<String>,
212 #[serde(default)]
213 pub env_key: Option<String>,
214 #[serde(default)]
215 pub extra_headers: Vec<(String, String)>,
216 #[serde(default)]
217 pub enabled: Option<bool>,
218}
219
220#[derive(Debug, Serialize, Deserialize)]
221pub(crate) struct OverrideModel {
222 pub provider: String,
223 pub id: String,
224 #[serde(default)]
225 pub name: Option<String>,
226 #[serde(default)]
227 pub cost_input: Option<f64>,
228 #[serde(default)]
229 pub cost_output: Option<f64>,
230 #[serde(default)]
231 pub context_window: Option<u32>,
232 #[serde(default)]
233 pub max_tokens: Option<u32>,
234}
235
236fn load_snapshot() -> Option<MdCatalog> {
252 let compressed: &[u8] = oxicode_ai::catalog::snapshot_gzip_bytes();
253 let mut decoder = flate2::read::GzDecoder::new(compressed);
254 let mut json = String::new();
255 decoder.read_to_string(&mut json).ok()?;
256 serde_json::from_str::<MdCatalog>(&json).ok()
257}
258
259pub(crate) fn protocol_for(npm: &str) -> CatalogProtocol {
269 match npm {
270 "@ai-sdk/anthropic" => CatalogProtocol::AnthropicMessages,
271 "@ai-sdk/google" => CatalogProtocol::GoogleGenerativeAi,
272 "@ai-sdk/google-vertex" | "@ai-sdk/google-vertex/anthropic" => {
273 CatalogProtocol::GoogleVertex
274 }
275 "@ai-sdk/azure" => CatalogProtocol::AzureOpenAiResponses,
276 "@ai-sdk/amazon-bedrock" => CatalogProtocol::BedrockConverseStream,
277 "@ai-sdk/openai" | "@ai-sdk/openai-compatible" => CatalogProtocol::OpenAiCompletions,
278 _ => CatalogProtocol::OpenAiCompatible,
280 }
281}
282
283pub(crate) fn materialize(
292 catalog: &MdCatalog,
293 user_overrides: &OverrideFile,
294) -> (
295 Vec<CatalogProviderEntry>,
296 BTreeMap<String, Vec<CatalogModelEntry>>,
297) {
298 let mut providers = Vec::new();
299 let mut models: BTreeMap<String, Vec<CatalogModelEntry>> = BTreeMap::new();
300
301 for (pid, mdprov) in &catalog.0 {
302 let provider_protocol = protocol_for(mdprov.npm.as_deref().unwrap_or(""));
303
304 providers.push(CatalogProviderEntry {
305 id: pid.clone(),
306 display_name: mdprov.name.clone(),
307 aliases: Vec::new(),
308 protocol: provider_protocol,
309 env_key: mdprov.env.first().cloned(),
310 extra_env_keys: mdprov.env.get(1..).unwrap_or(&[]).to_vec(),
311 base_url: mdprov.api.clone(),
312 extra_headers: Vec::new(),
313 category: String::new(),
314 description: String::new(),
315 default_enabled: true,
316 });
317
318 for (mid, mdmodel) in &mdprov.models {
319 let model_prov = mdmodel.provider.as_ref();
320 let model_npm = model_prov
321 .and_then(|p| p.npm.as_deref())
322 .unwrap_or_else(|| mdprov.npm.as_deref().unwrap_or(""));
323 let model_protocol = protocol_for(model_npm);
324 let model_base_url = model_prov
325 .and_then(|p| p.api.clone())
326 .filter(|s| !s.is_empty());
327
328 models
329 .entry(pid.clone())
330 .or_default()
331 .push(CatalogModelEntry {
332 provider: pid.clone(),
333 model_id: mid.clone(),
334 name: mdmodel.name.clone(),
335 protocol: model_protocol,
336 source: CatalogSource::Embedded,
337 base_url: model_base_url,
338 reasoning: mdmodel.reasoning,
339 supports_vision: mdmodel.attachment,
340 cost_input: mdmodel.cost.as_ref().map(|c| c.input).unwrap_or(0.0),
341 cost_output: mdmodel.cost.as_ref().map(|c| c.output).unwrap_or(0.0),
342 cost_cache_read: mdmodel
343 .cost
344 .as_ref()
345 .and_then(|c| c.cache_read)
346 .unwrap_or(0.0),
347 cost_cache_write: mdmodel
348 .cost
349 .as_ref()
350 .and_then(|c| c.cache_write)
351 .unwrap_or(0.0),
352 context_window: mdmodel.limit.context as u32,
353 max_tokens: mdmodel.limit.output as u32,
354 input_modalities: normalize_modalities(&mdmodel.modalities),
355 release_date: mdmodel.release_date.clone(),
356 status: mdmodel.status.clone(),
357 });
358 }
359 }
360
361 apply_user_overrides(&mut providers, &mut models, user_overrides);
362
363 (providers, models)
364}
365
366fn normalize_modalities(md: &Option<MdModalities>) -> Vec<String> {
367 match md {
368 Some(m) => match &m.input {
369 Some(input) if !input.is_empty() => input.clone(),
370 _ => vec!["text".to_string()],
371 },
372 None => vec!["text".to_string()],
373 }
374}
375
376fn apply_user_overrides(
377 providers: &mut Vec<CatalogProviderEntry>,
378 models: &mut BTreeMap<String, Vec<CatalogModelEntry>>,
379 overrides: &OverrideFile,
380) {
381 for ovr in &overrides.provider {
383 if let Some(slot) = providers.iter_mut().find(|p| p.id == ovr.id) {
384 if let Some(d) = &ovr.display_name {
385 slot.display_name = d.clone();
386 }
387 if let Some(b) = &ovr.base_url {
388 slot.base_url = Some(b.clone());
389 }
390 if let Some(k) = &ovr.env_key {
391 slot.env_key = Some(k.clone());
392 }
393 slot.extra_headers = ovr.extra_headers.clone();
394 if let Some(en) = ovr.enabled {
395 slot.default_enabled = en;
396 }
397 } else {
398 providers.push(CatalogProviderEntry {
399 id: ovr.id.clone(),
400 display_name: ovr.display_name.clone().unwrap_or_else(|| ovr.id.clone()),
401 aliases: Vec::new(),
402 protocol: CatalogProtocol::OpenAiCompatible,
403 env_key: ovr.env_key.clone(),
404 extra_env_keys: Vec::new(),
405 base_url: ovr.base_url.clone(),
406 extra_headers: ovr.extra_headers.clone(),
407 category: String::new(),
408 description: String::new(),
409 default_enabled: ovr.enabled.unwrap_or(true),
410 });
411 }
412 }
413 for ovr in &overrides.model {
415 let entry = CatalogModelEntry {
416 provider: ovr.provider.clone(),
417 model_id: ovr.id.clone(),
418 name: ovr.name.clone().unwrap_or_else(|| ovr.id.clone()),
419 protocol: CatalogProtocol::OpenAiCompatible,
420 source: CatalogSource::Override,
421 base_url: None,
422 reasoning: false,
423 supports_vision: false,
424 cost_input: ovr.cost_input.unwrap_or(0.0),
425 cost_output: ovr.cost_output.unwrap_or(0.0),
426 cost_cache_read: 0.0,
427 cost_cache_write: 0.0,
428 context_window: ovr.context_window.unwrap_or(0),
429 max_tokens: ovr.max_tokens.unwrap_or(0),
430 input_modalities: vec!["text".to_string()],
431 release_date: None,
432 status: None,
433 };
434 let list = models.entry(ovr.provider.clone()).or_default();
435 if let Some(slot) = list.iter_mut().find(|m| m.model_id == ovr.id) {
436 if let Some(n) = ovr.name.clone() {
438 slot.name = n;
439 }
440 if let Some(c) = ovr.cost_input {
441 slot.cost_input = c;
442 }
443 if let Some(c) = ovr.cost_output {
444 slot.cost_output = c;
445 }
446 if let Some(c) = ovr.context_window {
447 slot.context_window = c;
448 }
449 if let Some(m) = ovr.max_tokens {
450 slot.max_tokens = m;
451 }
452 slot.source = CatalogSource::Override;
453 } else {
454 list.push(entry);
455 }
456 }
457}
458
459struct Snapshot {
464 providers: Vec<CatalogProviderEntry>,
465 models: BTreeMap<String, BTreeMap<String, CatalogModelEntry>>,
467}
468
469impl Snapshot {
470 fn empty() -> Self {
471 Self {
472 providers: Vec::new(),
473 models: BTreeMap::new(),
474 }
475 }
476
477 fn stats(&self) -> (usize, usize) {
478 let model_count = self.models.values().map(|m| m.len()).sum();
479 (self.providers.len(), model_count)
480 }
481}
482
483pub struct FileModelCatalog {
491 state: Arc<RwLock<Snapshot>>,
492 tx: broadcast::Sender<CatalogEvent>,
493 config: CatalogConfig,
494}
495
496impl std::fmt::Debug for FileModelCatalog {
497 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498 let snap = self.state.read();
499 let (providers, models) = snap.stats();
500 f.debug_struct("FileModelCatalog")
501 .field("providers", &providers)
502 .field("models", &models)
503 .field("fetch_enabled", &self.config.fetch_enabled)
504 .finish_non_exhaustive()
505 }
506}
507
508impl FileModelCatalog {
509 pub async fn init(config: CatalogConfig) -> std::io::Result<Arc<Self>> {
513 let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
514 let cat = Arc::new(Self {
515 state: Arc::new(RwLock::new(Snapshot::empty())),
516 tx,
517 config,
518 });
519
520 cat.load_snapshot_internal();
522
523 if cat.try_load_fresh_cache().await.is_none() {
525 tracing::debug!("catalog: cache stale or missing");
526 }
527
528 cat.apply_user_overrides_internal();
530
531 cat.discover_local_all().await;
533
534 if cat.config.fetch_enabled && !cat.is_cache_fresh_internal() {
536 let _ = cat.refresh().await;
537 }
538
539 Ok(cat)
540 }
541
542 #[allow(dead_code)]
545 pub(crate) fn tx(&self) -> &broadcast::Sender<CatalogEvent> {
546 &self.tx
547 }
548
549 fn load_snapshot_internal(&self) {
552 let Some(md) = load_snapshot() else {
553 tracing::warn!("catalog: embedded SNAP missing or corrupt");
554 return;
555 };
556 let overrides = OverrideFile::default();
557 let (providers, models) = materialize(&md, &overrides);
558 let mut snap = self.state.write();
559 snap.providers = providers;
560 snap.models = models
561 .into_iter()
562 .map(|(pid, list)| {
563 let map = list.into_iter().map(|e| (e.model_id.clone(), e)).collect();
564 (pid, map)
565 })
566 .collect();
567 }
568
569 async fn try_load_fresh_cache(&self) -> Option<()> {
572 let path = self.config.cache_path.clone();
573 let window = self.config.mtime_window;
574 let res = tokio::task::spawn_blocking(move || read_cache_if_fresh(&path, window))
575 .await
576 .ok()
577 .flatten();
578 match res {
579 Some(catalog) => {
580 let overrides = OverrideFile::default();
581 let (providers, models) = materialize(&catalog, &overrides);
582 let mut snap = self.state.write();
583 snap.providers = providers;
584 snap.models = models
585 .into_iter()
586 .map(|(pid, list)| {
587 (
588 pid,
589 list.into_iter().map(|e| (e.model_id.clone(), e)).collect(),
590 )
591 })
592 .collect();
593 Some(())
594 }
595 None => None,
596 }
597 }
598
599 fn is_cache_fresh_internal(&self) -> bool {
600 let meta = match std::fs::metadata(&self.config.cache_path) {
601 Ok(m) => m,
602 Err(_) => return false,
603 };
604 let modified = match meta.modified() {
605 Ok(t) => t,
606 Err(_) => return false,
607 };
608 let age = match SystemTime::now().duration_since(modified) {
609 Ok(d) => d,
610 Err(_) => return false,
611 };
612 age <= self.config.mtime_window
613 }
614
615 fn apply_user_overrides_internal(&self) {
618 let Ok(body) = std::fs::read_to_string(&self.config.override_path) else {
619 return;
620 };
621 let Ok(overrides) = toml::from_str::<OverrideFile>(&body) else {
622 tracing::warn!("catalog: invalid override TOML, ignoring");
623 return;
624 };
625 let mut snap = self.state.write();
626 let mut providers = snap.providers.clone();
627 let mut models_map = snap.models.clone();
628 for ovr in &overrides.provider {
630 if let Some(slot) = providers.iter_mut().find(|p| p.id == ovr.id) {
631 if let Some(d) = &ovr.display_name {
632 slot.display_name = d.clone();
633 }
634 if let Some(b) = &ovr.base_url {
635 slot.base_url = Some(b.clone());
636 }
637 if let Some(k) = &ovr.env_key {
638 slot.env_key = Some(k.clone());
639 }
640 slot.extra_headers = ovr.extra_headers.clone();
641 if let Some(en) = ovr.enabled {
642 slot.default_enabled = en;
643 }
644 }
645 }
646 for ovr in &overrides.model {
647 let entry = CatalogModelEntry {
648 provider: ovr.provider.clone(),
649 model_id: ovr.id.clone(),
650 name: ovr.name.clone().unwrap_or_else(|| ovr.id.clone()),
651 protocol: CatalogProtocol::OpenAiCompatible,
652 source: CatalogSource::Override,
653 base_url: None,
654 reasoning: false,
655 supports_vision: false,
656 cost_input: ovr.cost_input.unwrap_or(0.0),
657 cost_output: ovr.cost_output.unwrap_or(0.0),
658 cost_cache_read: 0.0,
659 cost_cache_write: 0.0,
660 context_window: ovr.context_window.unwrap_or(0),
661 max_tokens: ovr.max_tokens.unwrap_or(0),
662 input_modalities: vec!["text".to_string()],
663 release_date: None,
664 status: None,
665 };
666 let inner = models_map.entry(ovr.provider.clone()).or_default();
667 if let Some((_, slot)) = inner.iter_mut().find(|(_, m)| m.model_id == ovr.id) {
668 if let Some(n) = ovr.name.clone() {
670 slot.name = n;
671 }
672 if let Some(c) = ovr.cost_input {
673 slot.cost_input = c;
674 }
675 if let Some(c) = ovr.cost_output {
676 slot.cost_output = c;
677 }
678 if let Some(c) = ovr.context_window {
679 slot.context_window = c;
680 }
681 if let Some(m) = ovr.max_tokens {
682 slot.max_tokens = m;
683 }
684 slot.source = CatalogSource::Override;
685 } else {
686 inner.insert(ovr.id.clone(), entry);
687 }
688 }
690 snap.providers = providers;
691 snap.models = models_map;
692 let _ = self.tx.send(CatalogEvent::OverrideApplied {
693 path: self.config.override_path.clone(),
694 provider_overrides: overrides.provider.len(),
695 model_overrides: overrides.model.len(),
696 });
697 }
698
699 async fn discover_local_all(&self) {
702 if self.config.local_discovery_urls.is_empty() {
703 return;
704 }
705 let urls = self.config.local_discovery_urls.clone();
706 for base in urls {
707 match fetch_local_models(&base).await {
708 Ok(entries) if !entries.is_empty() => {
709 let count = entries.len();
710 let mut snap = self.state.write();
711 for entry in entries {
712 let inner = snap.models.entry(entry.provider.clone()).or_default();
713 inner.insert(entry.model_id.clone(), entry);
714 }
715 let _ = self.tx.send(CatalogEvent::LocalDiscovered {
716 base_url: base,
717 model_count: count,
718 });
719 }
720 Ok(_) => {}
721 Err(e) => {
722 tracing::debug!(error = %e, base = %base, "local discovery failed");
723 }
724 }
725 }
726 }
727}
728
729fn read_cache_if_fresh(path: &Path, window: Duration) -> Option<MdCatalog> {
734 let meta = std::fs::metadata(path).ok()?;
735 let modified = meta.modified().ok()?;
736 let age = SystemTime::now().duration_since(modified).ok()?;
737 if age > window {
738 return None;
739 }
740 let body = std::fs::read_to_string(path).ok()?;
741 match serde_json::from_str::<MdCatalog>(&body) {
742 Ok(c) => Some(c),
743 Err(e) => {
744 tracing::warn!(error = %e, "cache corrupt, ignoring");
745 let _ = std::fs::remove_file(path);
746 None
747 }
748 }
749}
750
751enum FetchResult {
756 Updated(MdCatalog),
757 NotModified,
758}
759
760async fn fetch_conditional(url: &str, etag: Option<&str>, user_agent: &str) -> Option<FetchResult> {
761 let client = reqwest::Client::builder()
762 .timeout(FETCH_TIMEOUT)
763 .build()
764 .ok()?;
765 let full = format!("{}/api.json", url.trim_end_matches('/'));
766 for attempt in 0..FETCH_RETRIES {
767 let mut req = client.get(&full).header("User-Agent", user_agent);
768 if let Some(e) = etag {
769 req = req.header("If-None-Match", e);
770 }
771 match req.send().await {
772 Ok(resp) => {
773 let status = resp.status();
774 if status.as_u16() == 304 {
775 return Some(FetchResult::NotModified);
776 }
777 if status.is_success() {
778 let body = resp.text().await.ok()?;
779 return serde_json::from_str::<MdCatalog>(&body)
780 .ok()
781 .map(FetchResult::Updated);
782 }
783 }
784 Err(e) => {
785 tracing::warn!(error = %e, attempt, "fetch failed");
786 }
787 }
788 if attempt + 1 < FETCH_RETRIES {
789 tokio::time::sleep(RETRY_BACKOFF).await;
790 }
791 }
792 None
793}
794
795async fn fetch_local_models(base_url: &str) -> std::io::Result<Vec<CatalogModelEntry>> {
796 let client = reqwest::Client::builder()
797 .timeout(FETCH_TIMEOUT)
798 .build()
799 .map_err(io_err)?;
800 let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
801 #[derive(Deserialize)]
802 struct Resp {
803 data: Vec<LocalModel>,
804 }
805 #[derive(Deserialize)]
806 struct LocalModel {
807 id: String,
808 }
809 let resp = client
810 .get(&url)
811 .send()
812 .await
813 .map_err(io_err)?
814 .json::<Resp>()
815 .await
816 .map_err(io_err)?;
817 let provider_id = derive_local_provider(base_url);
818 let entries = resp
819 .data
820 .into_iter()
821 .map(|m| {
822 let known = oxicode_ai::model_db::find_entry_by_model_id(&m.id);
827 CatalogModelEntry {
828 provider: provider_id.clone(),
829 model_id: m.id.clone(),
830 name: m.id,
831 protocol: CatalogProtocol::OpenAiCompatible,
832 source: CatalogSource::Local,
833 base_url: Some(base_url.trim_end_matches('/').to_string()),
834 reasoning: known.map(|e| e.reasoning).unwrap_or(false),
835 supports_vision: known.map(|e| e.supports_vision()).unwrap_or(false),
836 cost_input: known.map(|e| e.cost_input.max(0.0)).unwrap_or(0.0),
837 cost_output: known.map(|e| e.cost_output.max(0.0)).unwrap_or(0.0),
838 cost_cache_read: known.map(|e| e.cost_cache_read.max(0.0)).unwrap_or(0.0),
839 cost_cache_write: known.map(|e| e.cost_cache_write.max(0.0)).unwrap_or(0.0),
840 context_window: known.map(|e| e.context_window).unwrap_or(0),
841 max_tokens: known.map(|e| e.max_tokens).unwrap_or(0),
842 input_modalities: vec!["text".to_string()],
843 release_date: None,
844 status: None,
845 }
846 })
847 .collect();
848 Ok(entries)
849}
850
851fn derive_local_provider(base_url: &str) -> String {
852 let trimmed = base_url
855 .trim_start_matches("http://")
856 .trim_start_matches("https://");
857 let host = trimmed.split(':').next().unwrap_or("local");
858 if host.is_empty() {
859 "local".to_string()
860 } else {
861 host.to_string()
862 }
863}
864
865fn io_err<E: std::fmt::Display>(e: E) -> std::io::Error {
866 std::io::Error::other(e.to_string())
867}
868
869impl ModelCatalog for FileModelCatalog {
874 fn list_providers(&self) -> Pin<Box<dyn Future<Output = SdkResult<Vec<String>>> + Send + '_>> {
875 let snap = self.state.read();
876 let mut ids: Vec<String> = snap.providers.iter().map(|p| p.id.clone()).collect();
877 ids.sort();
878 Box::pin(async move { Ok(ids) })
879 }
880
881 fn get_provider(
882 &self,
883 provider_id: &str,
884 ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogProviderEntry>>> + Send + '_>> {
885 let snap = self.state.read();
886 let entry = snap.providers.iter().find(|p| p.id == provider_id).cloned();
887 Box::pin(async move { Ok(entry) })
888 }
889
890 fn list_models(
891 &self,
892 provider_id: &str,
893 ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>> {
894 let snap = self.state.read();
895 let list = snap
896 .models
897 .get(provider_id)
898 .map(|m| m.values().cloned().collect())
899 .unwrap_or_default();
900 Box::pin(async move { Ok(list) })
901 }
902
903 fn get_model(
904 &self,
905 provider_id: &str,
906 model_id: &str,
907 ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogModelEntry>>> + Send + '_>> {
908 let snap = self.state.read();
909 let entry = snap
910 .models
911 .get(provider_id)
912 .and_then(|m| m.get(model_id))
913 .cloned();
914 Box::pin(async move { Ok(entry) })
915 }
916
917 fn search(
918 &self,
919 pattern: &str,
920 ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>> {
921 let snap = self.state.read();
922 let lower = pattern.to_lowercase();
923 let out: Vec<CatalogModelEntry> = snap
924 .models
925 .values()
926 .flat_map(|m| m.values())
927 .filter(|e| {
928 e.model_id.to_lowercase().contains(&lower)
929 || e.name.to_lowercase().contains(&lower)
930 || e.provider.to_lowercase().contains(&lower)
931 })
932 .cloned()
933 .collect();
934 Box::pin(async move { Ok(out) })
935 }
936
937 fn model_count(&self) -> Pin<Box<dyn Future<Output = SdkResult<usize>> + Send + '_>> {
938 let snap = self.state.read();
939 let count: usize = snap.models.values().map(|m| m.len()).sum();
940 Box::pin(async move { Ok(count) })
941 }
942
943 fn refresh(&self) -> Pin<Box<dyn Future<Output = SdkResult<RefreshOutcome>> + Send + '_>> {
944 let state = Arc::clone(&self.state);
945 let tx = self.tx.clone();
946 let config = self.config.clone();
947 Box::pin(async move {
948 if !config.fetch_enabled {
949 return Ok(RefreshOutcome::Offline {
950 reason: "fetch_disabled",
951 });
952 }
953 if is_cache_fresh_static(&config.cache_path, config.mtime_window) {
955 return Ok(RefreshOutcome::Unchanged);
956 }
957 let etag = std::fs::read_to_string(&config.etag_path)
958 .ok()
959 .map(|s| s.trim().to_string())
960 .filter(|s| !s.is_empty());
961 match fetch_conditional(&config.models_dev_url, etag.as_deref(), &config.user_agent)
962 .await
963 {
964 Some(FetchResult::Updated(md)) => {
965 let (providers, models) = materialize(&md, &OverrideFile::default());
966 let (pcount, mcount) = {
967 let mut snap = state.write();
968 snap.providers = providers;
969 snap.models = models
970 .into_iter()
971 .map(|(pid, list)| {
972 (
973 pid,
974 list.into_iter().map(|e| (e.model_id.clone(), e)).collect(),
975 )
976 })
977 .collect();
978 snap.stats()
979 };
980 if let Ok(body) = serde_json::to_string(&md) {
982 let _ = std::fs::create_dir_all(
983 config.cache_path.parent().unwrap_or(Path::new(".")),
984 );
985 let _ = std::fs::write(&config.cache_path, body);
986 }
987 let _ = filetime::set_file_mtime(
988 &config.cache_path,
989 filetime::FileTime::from_system_time(SystemTime::now()),
990 );
991 let _ = tx.send(CatalogEvent::Updated {
992 provider_count: pcount,
993 model_count: mcount,
994 });
995 Ok(RefreshOutcome::Updated {
996 provider_count: pcount,
997 model_count: mcount,
998 })
999 }
1000 Some(FetchResult::NotModified) => {
1001 let _ = filetime::set_file_mtime(
1002 &config.cache_path,
1003 filetime::FileTime::from_system_time(SystemTime::now()),
1004 );
1005 Ok(RefreshOutcome::Unchanged)
1006 }
1007 None => {
1008 let (pcount, mcount) = state.read().stats();
1009 let _ = tx.send(CatalogEvent::RefreshFailed {
1010 reason: "network".into(),
1011 provider_count: pcount,
1012 model_count: mcount,
1013 });
1014 Ok(RefreshOutcome::Failed {
1015 reason: "network".into(),
1016 })
1017 }
1018 }
1019 })
1020 }
1021
1022 fn subscribe(&self) -> broadcast::Receiver<CatalogEvent> {
1023 self.tx.subscribe()
1024 }
1025
1026 fn list_providers_sync(&self) -> Vec<String> {
1032 let snap = self.state.read();
1033 let mut ids: Vec<String> = snap.providers.iter().map(|p| p.id.clone()).collect();
1034 ids.sort();
1035 ids
1036 }
1037
1038 fn get_provider_sync(&self, provider_id: &str) -> Option<CatalogProviderEntry> {
1039 let snap = self.state.read();
1040 snap.providers.iter().find(|p| p.id == provider_id).cloned()
1041 }
1042
1043 fn list_models_sync(&self, provider_id: &str) -> Vec<CatalogModelEntry> {
1044 let snap = self.state.read();
1045 snap.models
1046 .get(provider_id)
1047 .map(|m| m.values().cloned().collect())
1048 .unwrap_or_default()
1049 }
1050
1051 fn get_model_sync(&self, provider_id: &str, model_id: &str) -> Option<CatalogModelEntry> {
1052 let snap = self.state.read();
1053 snap.models
1054 .get(provider_id)
1055 .and_then(|m| m.get(model_id))
1056 .cloned()
1057 }
1058
1059 fn search_sync(&self, pattern: &str) -> Vec<CatalogModelEntry> {
1060 let snap = self.state.read();
1061 let lower = pattern.to_lowercase();
1062 snap.models
1063 .values()
1064 .flat_map(|m| m.values())
1065 .filter(|e| {
1066 e.model_id.to_lowercase().contains(&lower)
1067 || e.name.to_lowercase().contains(&lower)
1068 || e.provider.to_lowercase().contains(&lower)
1069 })
1070 .cloned()
1071 .collect()
1072 }
1073
1074 fn model_count_sync(&self) -> usize {
1075 let snap = self.state.read();
1076 snap.models.values().map(|m| m.len()).sum()
1077 }
1078}
1079
1080fn is_cache_fresh_static(path: &Path, window: Duration) -> bool {
1081 let meta = match std::fs::metadata(path) {
1082 Ok(m) => m,
1083 Err(_) => return false,
1084 };
1085 let modified = match meta.modified() {
1086 Ok(t) => t,
1087 Err(_) => return false,
1088 };
1089 let age = match SystemTime::now().duration_since(modified) {
1090 Ok(d) => d,
1091 Err(_) => return false,
1092 };
1093 age <= window
1094}
1095
1096#[cfg(test)]
1101mod tests {
1102 use super::*;
1103 use crate::AuthMethod;
1104
1105 #[test]
1106 fn protocol_for_anthropic() {
1107 assert_eq!(
1108 protocol_for("@ai-sdk/anthropic"),
1109 CatalogProtocol::AnthropicMessages
1110 );
1111 }
1112 #[test]
1113 fn protocol_for_google() {
1114 assert_eq!(
1115 protocol_for("@ai-sdk/google"),
1116 CatalogProtocol::GoogleGenerativeAi
1117 );
1118 }
1119 #[test]
1120 fn protocol_for_openai_compat() {
1121 assert_eq!(
1122 protocol_for("@ai-sdk/openai-compatible"),
1123 CatalogProtocol::OpenAiCompletions
1124 );
1125 }
1126 #[test]
1127 fn protocol_for_unknown_is_openai_compatible() {
1128 assert_eq!(
1129 protocol_for("some-new-sdk"),
1130 CatalogProtocol::OpenAiCompatible
1131 );
1132 }
1133 #[test]
1134 fn protocol_for_empty_is_openai_compatible() {
1135 assert_eq!(protocol_for(""), CatalogProtocol::OpenAiCompatible);
1136 }
1137
1138 #[test]
1139 fn default_auth_for_anthropic_is_xapikey() {
1140 assert_eq!(
1141 CatalogProtocol::AnthropicMessages.default_auth(),
1142 AuthMethod::XApiKey
1143 );
1144 }
1145 #[test]
1146 fn default_auth_for_azure_is_apikey() {
1147 assert_eq!(
1148 CatalogProtocol::AzureOpenAiResponses.default_auth(),
1149 AuthMethod::ApiKey
1150 );
1151 }
1152 #[test]
1153 fn default_auth_for_google_is_none() {
1154 assert_eq!(
1155 CatalogProtocol::GoogleVertex.default_auth(),
1156 AuthMethod::None
1157 );
1158 assert_eq!(
1159 CatalogProtocol::GoogleGenerativeAi.default_auth(),
1160 AuthMethod::None
1161 );
1162 assert_eq!(
1163 CatalogProtocol::BedrockConverseStream.default_auth(),
1164 AuthMethod::None
1165 );
1166 }
1167 #[test]
1168 fn default_auth_for_openai_compat_is_bearer() {
1169 assert_eq!(
1170 CatalogProtocol::OpenAiCompletions.default_auth(),
1171 AuthMethod::Bearer
1172 );
1173 assert_eq!(
1174 CatalogProtocol::OpenAiCompatible.default_auth(),
1175 AuthMethod::Bearer
1176 );
1177 assert_eq!(
1178 CatalogProtocol::OpenAiResponses.default_auth(),
1179 AuthMethod::Bearer
1180 );
1181 }
1182
1183 #[test]
1184 fn as_oxicode_api_round_trip() {
1185 use oxicode_ai::Api;
1186 assert_eq!(
1187 CatalogProtocol::AnthropicMessages.as_oxicode_api(),
1188 Api::AnthropicMessages
1189 );
1190 assert_eq!(
1191 CatalogProtocol::OpenAiCompletions.as_oxicode_api(),
1192 Api::OpenAiCompletions
1193 );
1194 assert_eq!(
1195 CatalogProtocol::OpenAiCompatible.as_oxicode_api(),
1196 Api::OpenAiCompletions
1197 );
1198 assert_eq!(
1199 CatalogProtocol::GoogleGenerativeAi.as_oxicode_api(),
1200 Api::GoogleGenerativeAi
1201 );
1202 }
1203
1204 #[test]
1205 fn snapshot_loads_and_has_expected_size() {
1206 let catalog = load_snapshot().expect("SNAP must load");
1207 assert!(!catalog.0.is_empty(), "SNAP should have providers");
1208 let model_count: usize = catalog.0.values().map(|p| p.models.len()).sum();
1209 assert!(
1210 model_count > 1000,
1211 "SNAP should have many models, got {model_count}"
1212 );
1213 }
1214
1215 #[test]
1216 fn materialize_produces_nonzero_entries() {
1217 let catalog = load_snapshot().expect("SNAP");
1218 let (providers, models) = materialize(&catalog, &OverrideFile::default());
1219 assert!(!providers.is_empty());
1220 let count: usize = models.values().map(|v| v.len()).sum();
1221 assert!(count > 0);
1222 }
1223
1224 #[test]
1225 fn override_replaces_existing_model() {
1226 let mut providers = vec![CatalogProviderEntry {
1227 id: "test".into(),
1228 display_name: "Original".into(),
1229 aliases: vec![],
1230 protocol: CatalogProtocol::OpenAiCompletions,
1231 env_key: Some("TEST_KEY".into()),
1232 extra_env_keys: vec![],
1233 base_url: Some("https://api.test.com".into()),
1234 extra_headers: vec![],
1235 category: String::new(),
1236 description: String::new(),
1237 default_enabled: true,
1238 }];
1239 let mut models: BTreeMap<String, Vec<CatalogModelEntry>> = BTreeMap::new();
1240 models.insert(
1241 "test".into(),
1242 vec![CatalogModelEntry {
1243 provider: "test".into(),
1244 model_id: "test-model".into(),
1245 name: "Original".into(),
1246 protocol: CatalogProtocol::OpenAiCompletions,
1247 source: CatalogSource::Embedded,
1248 base_url: None,
1249 reasoning: false,
1250 supports_vision: false,
1251 cost_input: 0.0,
1252 cost_output: 0.0,
1253 cost_cache_read: 0.0,
1254 cost_cache_write: 0.0,
1255 context_window: 1000,
1256 max_tokens: 100,
1257 input_modalities: vec!["text".into()],
1258 release_date: None,
1259 status: None,
1260 }],
1261 );
1262 let overrides = OverrideFile {
1263 model: vec![OverrideModel {
1264 provider: "test".into(),
1265 id: "test-model".into(),
1266 name: Some("Overridden".into()),
1267 cost_input: Some(99.0),
1268 cost_output: None,
1269 context_window: None,
1270 max_tokens: None,
1271 }],
1272 ..Default::default()
1273 };
1274 apply_user_overrides(&mut providers, &mut models, &overrides);
1275 let entry = models
1276 .get("test")
1277 .unwrap()
1278 .iter()
1279 .find(|m| m.model_id == "test-model")
1280 .unwrap();
1281 assert_eq!(entry.name, "Overridden");
1282 assert_eq!(entry.source, CatalogSource::Override);
1283 assert!((entry.cost_input - 99.0).abs() < 1e-9);
1284 assert_eq!(entry.context_window, 1000, "untouched field kept");
1285 }
1286}