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| CatalogModelEntry {
822 provider: provider_id.clone(),
823 model_id: m.id.clone(),
824 name: m.id,
825 protocol: CatalogProtocol::OpenAiCompatible,
826 source: CatalogSource::Local,
827 base_url: Some(base_url.trim_end_matches('/').to_string()),
828 reasoning: false,
829 supports_vision: false,
830 cost_input: 0.0,
831 cost_output: 0.0,
832 cost_cache_read: 0.0,
833 cost_cache_write: 0.0,
834 context_window: 0,
835 max_tokens: 0,
836 input_modalities: vec!["text".to_string()],
837 release_date: None,
838 status: None,
839 })
840 .collect();
841 Ok(entries)
842}
843
844fn derive_local_provider(base_url: &str) -> String {
845 let trimmed = base_url
848 .trim_start_matches("http://")
849 .trim_start_matches("https://");
850 let host = trimmed.split(':').next().unwrap_or("local");
851 if host.is_empty() {
852 "local".to_string()
853 } else {
854 host.to_string()
855 }
856}
857
858fn io_err<E: std::fmt::Display>(e: E) -> std::io::Error {
859 std::io::Error::other(e.to_string())
860}
861
862impl ModelCatalog for FileModelCatalog {
867 fn list_providers(&self) -> Pin<Box<dyn Future<Output = SdkResult<Vec<String>>> + Send + '_>> {
868 let snap = self.state.read();
869 let mut ids: Vec<String> = snap.providers.iter().map(|p| p.id.clone()).collect();
870 ids.sort();
871 Box::pin(async move { Ok(ids) })
872 }
873
874 fn get_provider(
875 &self,
876 provider_id: &str,
877 ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogProviderEntry>>> + Send + '_>> {
878 let snap = self.state.read();
879 let entry = snap.providers.iter().find(|p| p.id == provider_id).cloned();
880 Box::pin(async move { Ok(entry) })
881 }
882
883 fn list_models(
884 &self,
885 provider_id: &str,
886 ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>> {
887 let snap = self.state.read();
888 let list = snap
889 .models
890 .get(provider_id)
891 .map(|m| m.values().cloned().collect())
892 .unwrap_or_default();
893 Box::pin(async move { Ok(list) })
894 }
895
896 fn get_model(
897 &self,
898 provider_id: &str,
899 model_id: &str,
900 ) -> Pin<Box<dyn Future<Output = SdkResult<Option<CatalogModelEntry>>> + Send + '_>> {
901 let snap = self.state.read();
902 let entry = snap
903 .models
904 .get(provider_id)
905 .and_then(|m| m.get(model_id))
906 .cloned();
907 Box::pin(async move { Ok(entry) })
908 }
909
910 fn search(
911 &self,
912 pattern: &str,
913 ) -> Pin<Box<dyn Future<Output = SdkResult<Vec<CatalogModelEntry>>> + Send + '_>> {
914 let snap = self.state.read();
915 let lower = pattern.to_lowercase();
916 let out: Vec<CatalogModelEntry> = snap
917 .models
918 .values()
919 .flat_map(|m| m.values())
920 .filter(|e| {
921 e.model_id.to_lowercase().contains(&lower)
922 || e.name.to_lowercase().contains(&lower)
923 || e.provider.to_lowercase().contains(&lower)
924 })
925 .cloned()
926 .collect();
927 Box::pin(async move { Ok(out) })
928 }
929
930 fn model_count(&self) -> Pin<Box<dyn Future<Output = SdkResult<usize>> + Send + '_>> {
931 let snap = self.state.read();
932 let count: usize = snap.models.values().map(|m| m.len()).sum();
933 Box::pin(async move { Ok(count) })
934 }
935
936 fn refresh(&self) -> Pin<Box<dyn Future<Output = SdkResult<RefreshOutcome>> + Send + '_>> {
937 let state = Arc::clone(&self.state);
938 let tx = self.tx.clone();
939 let config = self.config.clone();
940 Box::pin(async move {
941 if !config.fetch_enabled {
942 return Ok(RefreshOutcome::Offline {
943 reason: "fetch_disabled",
944 });
945 }
946 if is_cache_fresh_static(&config.cache_path, config.mtime_window) {
948 return Ok(RefreshOutcome::Unchanged);
949 }
950 let etag = std::fs::read_to_string(&config.etag_path)
951 .ok()
952 .map(|s| s.trim().to_string())
953 .filter(|s| !s.is_empty());
954 match fetch_conditional(&config.models_dev_url, etag.as_deref(), &config.user_agent)
955 .await
956 {
957 Some(FetchResult::Updated(md)) => {
958 let (providers, models) = materialize(&md, &OverrideFile::default());
959 let (pcount, mcount) = {
960 let mut snap = state.write();
961 snap.providers = providers;
962 snap.models = models
963 .into_iter()
964 .map(|(pid, list)| {
965 (
966 pid,
967 list.into_iter().map(|e| (e.model_id.clone(), e)).collect(),
968 )
969 })
970 .collect();
971 snap.stats()
972 };
973 if let Ok(body) = serde_json::to_string(&md) {
975 let _ = std::fs::create_dir_all(
976 config.cache_path.parent().unwrap_or(Path::new(".")),
977 );
978 let _ = std::fs::write(&config.cache_path, body);
979 }
980 let _ = filetime::set_file_mtime(
981 &config.cache_path,
982 filetime::FileTime::from_system_time(SystemTime::now()),
983 );
984 let _ = tx.send(CatalogEvent::Updated {
985 provider_count: pcount,
986 model_count: mcount,
987 });
988 Ok(RefreshOutcome::Updated {
989 provider_count: pcount,
990 model_count: mcount,
991 })
992 }
993 Some(FetchResult::NotModified) => {
994 let _ = filetime::set_file_mtime(
995 &config.cache_path,
996 filetime::FileTime::from_system_time(SystemTime::now()),
997 );
998 Ok(RefreshOutcome::Unchanged)
999 }
1000 None => {
1001 let (pcount, mcount) = state.read().stats();
1002 let _ = tx.send(CatalogEvent::RefreshFailed {
1003 reason: "network".into(),
1004 provider_count: pcount,
1005 model_count: mcount,
1006 });
1007 Ok(RefreshOutcome::Failed {
1008 reason: "network".into(),
1009 })
1010 }
1011 }
1012 })
1013 }
1014
1015 fn subscribe(&self) -> broadcast::Receiver<CatalogEvent> {
1016 self.tx.subscribe()
1017 }
1018
1019 fn list_providers_sync(&self) -> Vec<String> {
1025 let snap = self.state.read();
1026 let mut ids: Vec<String> = snap.providers.iter().map(|p| p.id.clone()).collect();
1027 ids.sort();
1028 ids
1029 }
1030
1031 fn get_provider_sync(&self, provider_id: &str) -> Option<CatalogProviderEntry> {
1032 let snap = self.state.read();
1033 snap.providers.iter().find(|p| p.id == provider_id).cloned()
1034 }
1035
1036 fn list_models_sync(&self, provider_id: &str) -> Vec<CatalogModelEntry> {
1037 let snap = self.state.read();
1038 snap.models
1039 .get(provider_id)
1040 .map(|m| m.values().cloned().collect())
1041 .unwrap_or_default()
1042 }
1043
1044 fn get_model_sync(&self, provider_id: &str, model_id: &str) -> Option<CatalogModelEntry> {
1045 let snap = self.state.read();
1046 snap.models
1047 .get(provider_id)
1048 .and_then(|m| m.get(model_id))
1049 .cloned()
1050 }
1051
1052 fn search_sync(&self, pattern: &str) -> Vec<CatalogModelEntry> {
1053 let snap = self.state.read();
1054 let lower = pattern.to_lowercase();
1055 snap.models
1056 .values()
1057 .flat_map(|m| m.values())
1058 .filter(|e| {
1059 e.model_id.to_lowercase().contains(&lower)
1060 || e.name.to_lowercase().contains(&lower)
1061 || e.provider.to_lowercase().contains(&lower)
1062 })
1063 .cloned()
1064 .collect()
1065 }
1066
1067 fn model_count_sync(&self) -> usize {
1068 let snap = self.state.read();
1069 snap.models.values().map(|m| m.len()).sum()
1070 }
1071}
1072
1073fn is_cache_fresh_static(path: &Path, window: Duration) -> bool {
1074 let meta = match std::fs::metadata(path) {
1075 Ok(m) => m,
1076 Err(_) => return false,
1077 };
1078 let modified = match meta.modified() {
1079 Ok(t) => t,
1080 Err(_) => return false,
1081 };
1082 let age = match SystemTime::now().duration_since(modified) {
1083 Ok(d) => d,
1084 Err(_) => return false,
1085 };
1086 age <= window
1087}
1088
1089#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use crate::AuthMethod;
1097
1098 #[test]
1099 fn protocol_for_anthropic() {
1100 assert_eq!(
1101 protocol_for("@ai-sdk/anthropic"),
1102 CatalogProtocol::AnthropicMessages
1103 );
1104 }
1105 #[test]
1106 fn protocol_for_google() {
1107 assert_eq!(
1108 protocol_for("@ai-sdk/google"),
1109 CatalogProtocol::GoogleGenerativeAi
1110 );
1111 }
1112 #[test]
1113 fn protocol_for_openai_compat() {
1114 assert_eq!(
1115 protocol_for("@ai-sdk/openai-compatible"),
1116 CatalogProtocol::OpenAiCompletions
1117 );
1118 }
1119 #[test]
1120 fn protocol_for_unknown_is_openai_compatible() {
1121 assert_eq!(
1122 protocol_for("some-new-sdk"),
1123 CatalogProtocol::OpenAiCompatible
1124 );
1125 }
1126 #[test]
1127 fn protocol_for_empty_is_openai_compatible() {
1128 assert_eq!(protocol_for(""), CatalogProtocol::OpenAiCompatible);
1129 }
1130
1131 #[test]
1132 fn default_auth_for_anthropic_is_xapikey() {
1133 assert_eq!(
1134 CatalogProtocol::AnthropicMessages.default_auth(),
1135 AuthMethod::XApiKey
1136 );
1137 }
1138 #[test]
1139 fn default_auth_for_azure_is_apikey() {
1140 assert_eq!(
1141 CatalogProtocol::AzureOpenAiResponses.default_auth(),
1142 AuthMethod::ApiKey
1143 );
1144 }
1145 #[test]
1146 fn default_auth_for_google_is_none() {
1147 assert_eq!(
1148 CatalogProtocol::GoogleVertex.default_auth(),
1149 AuthMethod::None
1150 );
1151 assert_eq!(
1152 CatalogProtocol::GoogleGenerativeAi.default_auth(),
1153 AuthMethod::None
1154 );
1155 assert_eq!(
1156 CatalogProtocol::BedrockConverseStream.default_auth(),
1157 AuthMethod::None
1158 );
1159 }
1160 #[test]
1161 fn default_auth_for_openai_compat_is_bearer() {
1162 assert_eq!(
1163 CatalogProtocol::OpenAiCompletions.default_auth(),
1164 AuthMethod::Bearer
1165 );
1166 assert_eq!(
1167 CatalogProtocol::OpenAiCompatible.default_auth(),
1168 AuthMethod::Bearer
1169 );
1170 assert_eq!(
1171 CatalogProtocol::OpenAiResponses.default_auth(),
1172 AuthMethod::Bearer
1173 );
1174 }
1175
1176 #[test]
1177 fn as_oxicode_api_round_trip() {
1178 use oxicode_ai::Api;
1179 assert_eq!(
1180 CatalogProtocol::AnthropicMessages.as_oxicode_api(),
1181 Api::AnthropicMessages
1182 );
1183 assert_eq!(
1184 CatalogProtocol::OpenAiCompletions.as_oxicode_api(),
1185 Api::OpenAiCompletions
1186 );
1187 assert_eq!(
1188 CatalogProtocol::OpenAiCompatible.as_oxicode_api(),
1189 Api::OpenAiCompletions
1190 );
1191 assert_eq!(
1192 CatalogProtocol::GoogleGenerativeAi.as_oxicode_api(),
1193 Api::GoogleGenerativeAi
1194 );
1195 }
1196
1197 #[test]
1198 fn snapshot_loads_and_has_expected_size() {
1199 let catalog = load_snapshot().expect("SNAP must load");
1200 assert!(!catalog.0.is_empty(), "SNAP should have providers");
1201 let model_count: usize = catalog.0.values().map(|p| p.models.len()).sum();
1202 assert!(
1203 model_count > 1000,
1204 "SNAP should have many models, got {model_count}"
1205 );
1206 }
1207
1208 #[test]
1209 fn materialize_produces_nonzero_entries() {
1210 let catalog = load_snapshot().expect("SNAP");
1211 let (providers, models) = materialize(&catalog, &OverrideFile::default());
1212 assert!(!providers.is_empty());
1213 let count: usize = models.values().map(|v| v.len()).sum();
1214 assert!(count > 0);
1215 }
1216
1217 #[test]
1218 fn override_replaces_existing_model() {
1219 let mut providers = vec![CatalogProviderEntry {
1220 id: "test".into(),
1221 display_name: "Original".into(),
1222 aliases: vec![],
1223 protocol: CatalogProtocol::OpenAiCompletions,
1224 env_key: Some("TEST_KEY".into()),
1225 extra_env_keys: vec![],
1226 base_url: Some("https://api.test.com".into()),
1227 extra_headers: vec![],
1228 category: String::new(),
1229 description: String::new(),
1230 default_enabled: true,
1231 }];
1232 let mut models: BTreeMap<String, Vec<CatalogModelEntry>> = BTreeMap::new();
1233 models.insert(
1234 "test".into(),
1235 vec![CatalogModelEntry {
1236 provider: "test".into(),
1237 model_id: "test-model".into(),
1238 name: "Original".into(),
1239 protocol: CatalogProtocol::OpenAiCompletions,
1240 source: CatalogSource::Embedded,
1241 base_url: None,
1242 reasoning: false,
1243 supports_vision: false,
1244 cost_input: 0.0,
1245 cost_output: 0.0,
1246 cost_cache_read: 0.0,
1247 cost_cache_write: 0.0,
1248 context_window: 1000,
1249 max_tokens: 100,
1250 input_modalities: vec!["text".into()],
1251 release_date: None,
1252 status: None,
1253 }],
1254 );
1255 let overrides = OverrideFile {
1256 model: vec![OverrideModel {
1257 provider: "test".into(),
1258 id: "test-model".into(),
1259 name: Some("Overridden".into()),
1260 cost_input: Some(99.0),
1261 cost_output: None,
1262 context_window: None,
1263 max_tokens: None,
1264 }],
1265 ..Default::default()
1266 };
1267 apply_user_overrides(&mut providers, &mut models, &overrides);
1268 let entry = models
1269 .get("test")
1270 .unwrap()
1271 .iter()
1272 .find(|m| m.model_id == "test-model")
1273 .unwrap();
1274 assert_eq!(entry.name, "Overridden");
1275 assert_eq!(entry.source, CatalogSource::Override);
1276 assert!((entry.cost_input - 99.0).abs() < 1e-9);
1277 assert_eq!(entry.context_window, 1000, "untouched field kept");
1278 }
1279}