Skip to main content

oxicode_sdk/ports/fs/
catalog.rs

1//! File-based implementation of [`ModelCatalog`].
2//!
3//! Mirrors the legacy `oxicode-ai/src/catalog/models_dev.rs` logic. SNAP is
4//! loaded from `oxicode-ai/data/catalog/_snapshot.json.gz` (embedded at compile
5//! time), then layered with the runtime cache (mtime + ETag conditional
6//! GET) and user overrides from `~/.oxicode/catalog/overrides.toml`.
7//!
8//! See `docs/designs/2026-06-17-catalog-port-design.md` (v3) §6 for
9//! the full architecture rationale.
10
11use 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
29// ═══════════════════════════════════════════════════════════════════════════
30// Tunables (read from env at init time)
31// ═══════════════════════════════════════════════════════════════════════════
32
33const 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// ═══════════════════════════════════════════════════════════════════════════
42// Configuration
43// ═══════════════════════════════════════════════════════════════════════════
44
45/// Configuration for [`FileModelCatalog::init`].
46///
47/// All paths default to conventional locations (`~/.oxicode/...`). Override
48/// for tests via `tempfile::TempDir` paths.
49#[derive(Debug, Clone)]
50pub struct CatalogConfig {
51    /// models.dev cache + JSON store. Default: `~/.oxicode/cache/models-dev.json`.
52    pub cache_path: PathBuf,
53    /// ETag sidecar file. Default: `~/.oxicode/cache/models-dev.json.etag`.
54    pub etag_path: PathBuf,
55    /// User overrides TOML. Default: `~/.oxicode/catalog/overrides.toml`.
56    pub override_path: PathBuf,
57    /// mtime freshness window for the cache. Default: 1 hour.
58    pub mtime_window: Duration,
59    /// If `false`, never touch the network. Default: true.
60    pub fetch_enabled: bool,
61    /// models.dev base URL. Default: `https://models.dev`.
62    pub models_dev_url: String,
63    /// User-Agent header value. Default: `oxicode-sdk/<version>`.
64    pub user_agent: String,
65    /// Optional local servers (`ollama`, `lmstudio`, etc.) to probe via
66    /// `/v1/models` at init time. Empty = skip.
67    pub local_discovery_urls: Vec<String>,
68    /// Snapshot gzip path. Used at init time to verify the embed exists;
69    /// not used directly (`include_bytes!` happens at compile time).
70    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// ═══════════════════════════════════════════════════════════════════════════
93// models.dev JSON schema (mirrors upstream `api.json`)
94// ═══════════════════════════════════════════════════════════════════════════
95
96#[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// ═══════════════════════════════════════════════════════════════════════════
194// User override TOML schema (Layer 2)
195// ═══════════════════════════════════════════════════════════════════════════
196
197#[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
236// ═══════════════════════════════════════════════════════════════════════════
237// Snapshot loading
238// ═══════════════════════════════════════════════════════════════════════════
239
240/// Decompress and parse the compile-time embedded SNAP.
241///
242/// The raw bytes come from [`oxicode_ai::catalog::snapshot_gzip_bytes`] — the
243/// single source of truth for the snapshot, owned by `oxicode-ai`. `oxicode-sdk`
244/// does **not** `include_bytes!` the file directly: the snapshot lives in
245/// `oxicode-ai/data/catalog/` (a sibling crate), and a cross-crate
246/// `include_bytes!` path escapes the `oxicode-sdk` package root, which made the
247/// published crate uncompilable for downstream consumers (fixed in 0.37.1).
248/// Reading the bytes through the `oxicode-ai` API keeps the snapshot packaged
249/// exactly once and lets `oxicode-sdk` parse it with its own [`MdCatalog`]
250/// schema below.
251fn 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
259// ═══════════════════════════════════════════════════════════════════════════
260// Protocol resolution — npm → CatalogProtocol
261// ═══════════════════════════════════════════════════════════════════════════
262
263/// Map a models.dev `npm` string to oxicode's [`CatalogProtocol`].
264///
265/// This is the **only** protocol knowledge the SDK has. New protocol =
266/// add a variant + a match arm here + a bridge dispatch (PR 3). Unknown
267/// npm values fall back to [`CatalogProtocol::OpenAiCompatible`].
268pub(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        // unknown npm → OpenAI-compatible fallback (most gateways/aggregators)
279        _ => CatalogProtocol::OpenAiCompatible,
280    }
281}
282
283// ═══════════════════════════════════════════════════════════════════════════
284// Materialize: MdCatalog → (CatalogProviderEntry, CatalogModelEntry)
285// ═══════════════════════════════════════════════════════════════════════════
286
287/// Convert a [`MdCatalog`] into the SDK's catalog entries.
288///
289/// Applies per-model npm overrides, then user overrides from
290/// `OverrideFile` (Layer 2 — highest precedence).
291pub(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    // Provider overrides: replace entry with matching id, or push new.
382    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    // Model overrides: replace by (provider, id), or push new.
414    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            // Partial update: override only the fields that were set in TOML.
437            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
459// ═══════════════════════════════════════════════════════════════════════════
460// Snapshot (in-memory) — protected by RwLock
461// ═══════════════════════════════════════════════════════════════════════════
462
463struct Snapshot {
464    providers: Vec<CatalogProviderEntry>,
465    /// provider_id → (model_id → entry). Nested for O(1) `get_model`.
466    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
483// ═══════════════════════════════════════════════════════════════════════════
484// FileModelCatalog — the reference port impl
485// ═══════════════════════════════════════════════════════════════════════════
486
487/// File-based catalog backed by models.dev SNAP + runtime cache + user
488/// overrides. This is the reference impl of [`ModelCatalog`] used by
489/// `oxicode-cli` and similar products.
490pub 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    /// Build the catalog by loading SNAP + cache + overrides. If the cache
510    /// is stale and `fetch_enabled`, attempt one refresh (failure is
511    /// silent — SNAP serves as fallback).
512    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        // 1. SNAP (embedded at compile time)
521        cat.load_snapshot_internal();
522
523        // 2. Runtime cache (mtime fresh = keep, stale = discard)
524        if cat.try_load_fresh_cache().await.is_none() {
525            tracing::debug!("catalog: cache stale or missing");
526        }
527
528        // 3. User overrides (Layer 2 — highest precedence)
529        cat.apply_user_overrides_internal();
530
531        // 4. LOCAL discovery (optional)
532        cat.discover_local_all().await;
533
534        // 5. One refresh attempt if cache is stale (failure silent)
535        if cat.config.fetch_enabled && !cat.is_cache_fresh_internal() {
536            let _ = cat.refresh().await;
537        }
538
539        Ok(cat)
540    }
541
542    /// Get the embedded catalog event channel for the broadcast sender
543    /// (mainly for tests; production code uses `subscribe()`).
544    #[allow(dead_code)]
545    pub(crate) fn tx(&self) -> &broadcast::Sender<CatalogEvent> {
546        &self.tx
547    }
548
549    // ─── SNAP load ─────────────────────────────────────────────────────
550
551    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    // ─── Cache load ─────────────────────────────────────────────────────
570
571    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    // ─── User overrides ────────────────────────────────────────────────
616
617    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        // Apply via in-memory materialize-like pass
629        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                // Partial update (same as apply_user_overrides).
669                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            // (same pattern continues — handled in line 663 etc.)
689        }
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    // ─── Local discovery ───────────────────────────────────────────────
700
701    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
729// ═══════════════════════════════════════════════════════════════════════════
730// Cache I/O helpers (sync, called from spawn_blocking)
731// ═══════════════════════════════════════════════════════════════════════════
732
733fn 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
751// ═══════════════════════════════════════════════════════════════════════════
752// HTTP fetch with ETag conditional GET
753// ═══════════════════════════════════════════════════════════════════════════
754
755enum 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    // Derive provider id from base URL host (strip port).
846    // e.g. "http://localhost:11434" -> "localhost".
847    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
862// ═══════════════════════════════════════════════════════════════════════════
863// Port trait impl
864// ═══════════════════════════════════════════════════════════════════════════
865
866impl 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            // Fast-path: cache fresh → no HTTP
947            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                    // Best-effort persist (fire-and-forget).
974                    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    // ── Sync read-only API ───────────────────────────────────────────────
1020    // These acquire the `RwLock` read guard, clone the requested data, and
1021    // return immediately. No I/O. They reflect the currently loaded snapshot
1022    // (which may lag a successful `refresh()` until the consumer re-queries).
1023
1024    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// ═══════════════════════════════════════════════════════════════════════════
1090// Tests
1091// ═══════════════════════════════════════════════════════════════════════════
1092
1093#[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}