Skip to main content

oxicode_catalog/catalog/
models_dev.rs

1//! models.dev live enrichment (Layer 2.5 of the catalog).
2//!
3//! Fetches the community-maintained model catalog from
4//! <https://models.dev/api.json> (MIT, also used by opencode) and enriches
5//! the built-in Layer 1 TOML entries with up-to-date pricing, context
6//! windows, max output tokens, and reasoning flags.
7//!
8//! # Layering
9//!
10//! ```text
11//! Layer 1   built-in TOML (compiled in)           fallback
12//! Layer 2   user overrides (~/.oxicode/catalog/...)    wins
13//! Layer 2.5 models.dev enrichment (this module)   fills gaps / refreshes
14//! Layer 3   /v1/models runtime discovery          local servers
15//! ```
16//!
17//! Enrichment runs inside the consumer's model database (oxicode-ai's `model_db`) after
18//! Layer 2 overrides are applied. Only fields that are missing or
19//! unverifiable in Layer 1 are overwritten — see the precedence rules below.
20//!
21//! # Precedence (highest wins)
22//!
23//! 1. Layer 2 user override
24//! 2. models.dev enrichment (this module) — only positive prices / known
25//!    limits; never overwrites a verified Layer 1 value with a worse one
26//! 3. Layer 1 built-in TOML
27//!
28//! # Offline behavior
29//!
30//! If the cache is fresh, enrichment is near-instant (file read). If the
31//! cache is stale or absent, a live fetch is attempted (10s timeout, 2
32//! retries). On total failure, [`get`] returns `None` and Layer 1 is used
33//! unchanged — the application still works, only cost accuracy degrades.
34//!
35//! # Attribution
36//!
37//! Model data © [models.dev](https://models.dev) (MIT). See
38//! <https://github.com/sst/models.dev>.
39
40use std::collections::BTreeMap;
41use std::path::PathBuf;
42use std::sync::Arc;
43use std::sync::OnceLock;
44use std::time::Duration;
45use std::time::SystemTime;
46
47use serde::{Deserialize, Serialize};
48
49use crate::Api;
50use crate::catalog::provider::AuthMethod;
51
52// ---------------------------------------------------------------------------
53// Tunables
54// ---------------------------------------------------------------------------
55
56/// Local-only freshness window: if the cache file's mtime is within this
57/// window, no HTTP request is made at all (zero-cost). Default 1 hour.
58const DEFAULT_MTIME_WINDOW: Duration = Duration::from_secs(60 * 60);
59
60/// Per-request timeout for the live fetch.
61const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
62
63/// Number of retries on transient fetch failures.
64const FETCH_RETRIES: u32 = 2;
65
66/// Backoff between retries (first retry waits this long).
67const RETRY_BACKOFF: Duration = Duration::from_millis(200);
68
69/// Default models.dev endpoint.
70const DEFAULT_URL: &str = "https://models.dev";
71
72/// User-Agent sent to models.dev.
73const USER_AGENT: &str = concat!("oxicode/", env!("CARGO_PKG_VERSION"));
74
75// ---------------------------------------------------------------------------
76// Schema (mirrors models.dev `api.json`, see opencode `packages/core/src/models-dev.ts`)
77// ---------------------------------------------------------------------------
78
79/// Top-level catalog: provider id → provider.
80#[derive(Debug, Default, Serialize, Deserialize)]
81pub struct MdCatalog(pub BTreeMap<String, MdProvider>);
82
83/// A single provider entry.
84#[derive(Debug, Serialize, Deserialize)]
85pub struct MdProvider {
86    /// Display name.
87    #[allow(dead_code)]
88    pub name: String,
89    /// Environment variables that hold the API key.
90    #[allow(dead_code)]
91    pub env: Vec<String>,
92    /// AI SDK npm package identifying the API protocol.
93    #[serde(default)]
94    #[allow(dead_code)]
95    pub npm: Option<String>,
96    /// Native API base URL for OpenAI-compatible providers.
97    #[serde(default)]
98    #[allow(dead_code)]
99    pub api: Option<String>,
100    /// Link to provider documentation.
101    #[serde(default)]
102    #[allow(dead_code)]
103    pub doc: Option<String>,
104    /// Models served by this provider.
105    pub models: BTreeMap<String, MdModel>,
106}
107
108/// A single model entry — serialised from models.dev `api.json`.
109#[derive(Debug, Serialize, Deserialize)]
110pub struct MdModel {
111    /// Display name.
112    #[allow(dead_code)]
113    pub name: String,
114    /// Model family (e.g. "claude-sonnet", "gpt-4").
115    #[serde(default)]
116    #[allow(dead_code)]
117    pub family: Option<String>,
118    /// Whether the model supports reasoning / chain-of-thought.
119    pub reasoning: bool,
120    /// Whether the model supports tool calling.
121    #[serde(default)]
122    pub tool_call: bool,
123    /// Whether the model supports file attachments (images, PDFs).
124    #[serde(default)]
125    pub attachment: bool,
126    /// Whether the model supports temperature control.
127    #[serde(default)]
128    #[allow(dead_code)]
129    pub temperature: Option<bool>,
130    /// Whether the model supports structured output / JSON mode.
131    #[serde(default)]
132    #[allow(dead_code)]
133    pub structured_output: Option<bool>,
134    /// Knowledge cutoff date.
135    #[serde(default)]
136    #[allow(dead_code)]
137    pub knowledge: Option<String>,
138    /// Release date of the model.
139    #[serde(default)]
140    #[allow(dead_code)]
141    pub release_date: Option<String>,
142    /// Last update time of this entry.
143    #[serde(default)]
144    #[allow(dead_code)]
145    pub last_updated: Option<String>,
146    /// Whether the model uses open weights.
147    #[serde(default)]
148    #[allow(dead_code)]
149    pub open_weights: Option<bool>,
150    /// Whether the model supports interleaved thinking + tool calls.
151    #[serde(default)]
152    #[allow(dead_code)]
153    pub interleaved: Option<serde_json::Value>,
154    /// Reasoning options (effort levels, budget tokens).
155    #[serde(default)]
156    #[allow(dead_code)]
157    pub reasoning_options: Option<Vec<MdReasoningOption>>,
158    /// Token limits.
159    pub limit: MdLimit,
160    /// Pricing (USD per million tokens). Optional — some are free.
161    #[serde(default)]
162    pub cost: Option<MdCost>,
163    /// Supported input/output modalities.
164    #[serde(default)]
165    #[allow(dead_code)]
166    pub modalities: Option<MdModalities>,
167    /// Model status (alpha, beta, deprecated).
168    #[serde(default)]
169    #[allow(dead_code)]
170    pub status: Option<String>,
171    /// Per-model provider override (npm + api).
172    #[serde(default)]
173    pub provider: Option<MdModelProvider>,
174}
175
176/// Per-model provider override — lets a specific model use a different
177/// API protocol or endpoint than its parent provider.
178#[derive(Debug, Serialize, Deserialize)]
179pub struct MdModelProvider {
180    /// Override npm package (API protocol).
181    #[serde(default)]
182    pub npm: Option<String>,
183    /// Override API base URL (empty = inherit from parent).
184    #[serde(default)]
185    pub api: Option<String>,
186}
187
188/// Token limits.
189#[derive(Debug, Serialize, Deserialize)]
190pub struct MdLimit {
191    /// Maximum context window (total tokens).
192    pub context: f64,
193    /// Max input tokens (optional, for reasoning models with input budget).
194    #[serde(default)]
195    pub input: Option<f64>,
196    /// Maximum output tokens (maps to oxicode `max_tokens`).
197    pub output: f64,
198}
199
200/// Pricing. All values are USD per million tokens.
201#[derive(Debug, Serialize, Deserialize)]
202#[allow(missing_docs)]
203pub struct MdCost {
204    /// Cost per million input tokens.
205    pub input: f64,
206    /// Cost per million output tokens.
207    pub output: f64,
208    /// Cost per million cached read tokens, if billed separately.
209    #[serde(default)]
210    pub cache_read: Option<f64>,
211    /// Cost per million cached write tokens, if billed separately.
212    #[serde(default)]
213    pub cache_write: Option<f64>,
214    /// Tiered pricing (e.g. context-length-based tiers).
215    #[serde(default)]
216    pub tiers: Option<Vec<MdCostTier>>,
217    /// Context >200K pricing (Anthropic-specific extended pricing tier).
218    #[serde(default)]
219    pub context_over_200k: Option<MdCostTierData>,
220    /// Separate pricing for reasoning/thinking tokens.
221    #[serde(default)]
222    pub reasoning: Option<f64>,
223    /// Audio modality input pricing.
224    #[serde(default)]
225    pub input_audio: Option<f64>,
226    /// Audio modality output pricing.
227    #[serde(default)]
228    pub output_audio: Option<f64>,
229}
230
231/// A single pricing tier (used within `tiers` array).
232#[derive(Debug, Serialize, Deserialize)]
233#[allow(missing_docs)]
234pub struct MdCostTier {
235    pub input: f64,
236    pub output: f64,
237    #[serde(default)]
238    pub cache_read: Option<f64>,
239    #[serde(default)]
240    pub cache_write: Option<f64>,
241    pub tier: MdTierSpec,
242}
243
244#[derive(Debug, Serialize, Deserialize)]
245#[allow(missing_docs)]
246pub struct MdTierSpec {
247    #[serde(rename = "type")]
248    pub kind: String,
249    pub size: f64,
250}
251
252/// Context-over-200K pricing tier data (Anthropic-specific).
253#[derive(Debug, Serialize, Deserialize)]
254#[allow(missing_docs)]
255pub struct MdCostTierData {
256    pub input: f64,
257    pub output: f64,
258    #[serde(default)]
259    pub cache_read: Option<f64>,
260    #[serde(default)]
261    pub cache_write: Option<f64>,
262}
263
264/// Supported input/output modalities.
265#[derive(Debug, Serialize, Deserialize)]
266#[allow(missing_docs)]
267pub struct MdModalities {
268    #[serde(default)]
269    #[allow(dead_code)]
270    pub input: Option<Vec<String>>,
271    #[serde(default)]
272    #[allow(dead_code)]
273    pub output: Option<Vec<String>>,
274}
275
276/// Reasoning options (effort levels, budget tokens).
277#[derive(Debug, Serialize, Deserialize)]
278#[allow(missing_docs)]
279pub struct MdReasoningOption {
280    #[serde(rename = "type")]
281    pub kind: String,
282    #[serde(default)]
283    #[allow(dead_code)]
284    pub values: Option<Vec<Option<String>>>,
285    #[serde(default)]
286    #[allow(dead_code)]
287    pub min: Option<f64>,
288}
289
290// ---------------------------------------------------------------------------
291// Protocol resolver — npm → (Api + AuthMethod), 7줄 (본 설계 핵심)
292// ---------------------------------------------------------------------------
293
294/// Map a models.dev `npm` string to oxicode's API type and authentication method.
295///
296/// This is the **only** protocol knowledge oxicode has. For OpenAI-compatible
297/// providers, the base URL from `MdProvider.api` is used at materialize time.
298/// Fresh npm values not listed here default to OpenAI-compatible (`OpenAiCompletions`).
299pub fn protocol_for(npm: &str) -> (Api, AuthMethod) {
300    match npm {
301        "@ai-sdk/anthropic" => (Api::AnthropicMessages, AuthMethod::XApiKey),
302        "@ai-sdk/google" => (Api::GoogleGenerativeAi, AuthMethod::None),
303        "@ai-sdk/google-vertex" | "@ai-sdk/google-vertex/anthropic" => {
304            (Api::GoogleVertex, AuthMethod::None)
305        }
306        "@ai-sdk/azure" => (Api::AzureOpenAiResponses, AuthMethod::ApiKey),
307        "@ai-sdk/amazon-bedrock" => (Api::BedrockConverseStream, AuthMethod::None),
308        // @ai-sdk/openai, @ai-sdk/openai-compatible, groq, xai, togetherai,
309        // vercel, perplexity, cerebras, deepinfra, cohere, gateway, etc.
310        // And any unknown npm → OpenAI-compatible with Bearer auth.
311        _ => (Api::OpenAiCompletions, AuthMethod::Bearer),
312    }
313}
314
315// ---------------------------------------------------------------------------
316// NOTE: provider_map, reasoning_preserve, and enrich() were removed.
317// These were used by the legacy TOML enrichment path. With the materialize
318// approach (materialize.rs), models.dev data flows directly into
319// BuiltinProviderEntry/BuiltinModelEntry without per-entry enrichment.
320// ---------------------------------------------------------------------------
321
322// ---------------------------------------------------------------------------
323// Global state
324// ---------------------------------------------------------------------------
325
326/// Global enriched catalog, populated by [`init_models_dev`].
327///
328/// `Some(None)` after init means "init ran but no data was available"
329/// (offline + no cache); the inner `Option` distinguishes that from
330/// "init has not run yet" (`MODELS_DEV.get() == None`).
331static MODELS_DEV: OnceLock<Option<Arc<MdCatalog>>> = OnceLock::new();
332
333/// Initialize the models.dev catalog.
334///
335/// Fetches (or reads from cache) the catalog and stores it for later
336/// enrichment. Safe to call multiple times — subsequent calls are no-ops.
337/// Called once at bootstrap ([`crate`] consumers wire it in the CLI).
338pub async fn init_models_dev() {
339    if MODELS_DEV.get().is_some() {
340        return;
341    }
342    let result = fetch_with_fallback().await;
343    let arc_opt = result.map(Arc::new);
344    // `set` is a race-safe no-op if another thread won the init race.
345    let _ = MODELS_DEV.set(arc_opt);
346}
347
348/// Get the enriched catalog, if [`init_models_dev`] has run with data.
349///
350/// Returns `None` when init hasn't run, ran but found no data (offline), or
351/// enrichment is disabled. Enrichment gracefully falls back to Layer 1 in
352/// all these cases.
353pub fn get() -> Option<&'static MdCatalog> {
354    MODELS_DEV.get().and_then(|o| o.as_deref())
355}
356
357/// Force-refresh the models.dev cache.
358///
359/// Performs a conditional GET (ETag) regardless of the mtime window.
360/// The result is written to the cache file. The in-memory catalog is
361/// **not** updated (OnceLock is immutable) — the refreshed data takes
362/// effect on the next process start.
363///
364/// Returns `true` if the cache was updated (200), `false` if unchanged
365/// (304) or on error.
366pub async fn refresh() -> bool {
367    if !enabled() || fetch_disabled() {
368        return false;
369    }
370    let etag = read_etag();
371    match live_fetch_conditional(etag.as_deref()).await {
372        Some(ConditionalResult::NotModified) => {
373            tracing::info!("models.dev: already up to date (304)");
374            touch_cache_mtime();
375            false
376        }
377        Some(ConditionalResult::Updated(c, new_etag)) => {
378            write_cache_atomic(&c);
379            if let Some(e) = new_etag {
380                write_etag(&e);
381            }
382            tracing::info!("models.dev: cache refreshed");
383            true
384        }
385        None => {
386            tracing::warn!("models.dev: refresh failed");
387            false
388        }
389    }
390}
391
392/// Force-clear the cached catalog. Test-only.
393#[cfg(test)]
394pub fn reset_for_tests() {
395    // OnceLock cannot be reset; tests instead construct MdCatalog directly
396    // and call `enrich`. This stub documents that intent.
397}
398
399// ---------------------------------------------------------------------------
400// Fetch / cache
401// ---------------------------------------------------------------------------
402
403/// Resolve the cache path.
404///
405/// - `OXICODE_MODELS_DEV_CACHE_PATH` overrides the location (test/enterprise use)
406/// - otherwise `<product-home>/cache/models-dev.json` (`$OXICODE_HOME` or `~/.oxicode`)
407fn cache_path() -> Option<PathBuf> {
408    if let Ok(custom) = std::env::var("OXICODE_MODELS_DEV_CACHE_PATH")
409        && !custom.is_empty()
410    {
411        return Some(PathBuf::from(custom));
412    }
413    crate::product_env::cache_dir().map(|d| d.join("models-dev.json"))
414}
415
416/// Whether enrichment is enabled at all.
417///
418/// - `OXICODE_MODELS_DEV=off` → disabled
419/// - `OXICODE_MODELS_DEV=on` or `auto` (or unset) → enabled
420fn enabled() -> bool {
421    !matches!(
422        std::env::var("OXICODE_MODELS_DEV").as_deref(),
423        Ok("off") | Ok("OFF") | Ok("0") | Ok("false") | Ok("FALSE")
424    )
425}
426
427/// Whether live network fetch is forbidden (air-gapped mode).
428fn fetch_disabled() -> bool {
429    matches!(
430        std::env::var("OXICODE_MODELS_DEV_DISABLE_FETCH").as_deref(),
431        Ok("1") | Ok("true") | Ok("TRUE")
432    )
433}
434
435/// Configured models.dev endpoint.
436fn models_url() -> String {
437    std::env::var("OXICODE_MODELS_DEV_URL").unwrap_or_else(|_| DEFAULT_URL.to_string())
438}
439
440/// Configured mtime window (local-only freshness check).
441///
442/// `OXICODE_MODELS_DEV_MTIME_WINDOW` (seconds) overrides the default (1 hour).
443/// Within this window, no HTTP request is made — zero-cost cache hit.
444fn mtime_window() -> Duration {
445    std::env::var("OXICODE_MODELS_DEV_MTIME_WINDOW")
446        .ok()
447        .and_then(|s| s.parse().ok())
448        .map(Duration::from_secs)
449        .unwrap_or(DEFAULT_MTIME_WINDOW)
450}
451
452/// Whether to force a conditional GET regardless of mtime window.
453/// Set by `oxicode models refresh` or `OXICODE_MODELS_DEV_FORCE_REFRESH=1`.
454fn force_refresh() -> bool {
455    matches!(
456        std::env::var("OXICODE_MODELS_DEV_FORCE_REFRESH").as_deref(),
457        Ok("1") | Ok("true") | Ok("TRUE")
458    )
459}
460
461/// Cache-or-live fallback chain with conditional GET (ETag).
462///
463/// Sync resolution order:
464/// 1. If cache mtime is within `mtime_window()` (default 1h) and not forced →
465///    use cache, no HTTP (zero-cost).
466/// 2. Otherwise, conditional GET with `If-None-Match` (stored ETag).
467///    - `304 Not Modified` → cache is still valid, touch mtime, use cache.
468///    - `200 OK` → write new cache + ETag, use new data.
469/// 3. On fetch failure, use stale cache (any age) if available.
470async fn fetch_with_fallback() -> Option<MdCatalog> {
471    if !enabled() {
472        return None;
473    }
474
475    // 1) Fresh disk cache within mtime window (unless force_refresh).
476    if !force_refresh()
477        && let Some(c) = read_cache_if_fresh()
478    {
479        tracing::debug!("models.dev: using cache within mtime window");
480        return Some(c);
481    }
482
483    // 2) Conditional GET (unless air-gapped).
484    if !fetch_disabled() {
485        let etag = read_etag();
486        match live_fetch_conditional(etag.as_deref()).await {
487            Some(ConditionalResult::NotModified) => {
488                // 304 means our cached data is still valid. But if the cache
489                // file is missing/corrupt, we have the ETag but no data —
490                // fall through to a non-conditional fetch to recover.
491                if let Some(c) = read_cache_any() {
492                    tracing::debug!("models.dev: 304 Not Modified, touching cache mtime");
493                    touch_cache_mtime();
494                    return Some(c);
495                }
496                tracing::warn!("models.dev: 304 received but cache missing — refetching");
497                // Remove stale ETag and retry without conditional.
498                clear_etag();
499                if let Some(ConditionalResult::Updated(c, new_etag)) =
500                    live_fetch_conditional(None).await
501                {
502                    write_cache_atomic(&c);
503                    if let Some(e) = new_etag {
504                        write_etag(&e);
505                    }
506                    return Some(c);
507                }
508            }
509            Some(ConditionalResult::Updated(c, new_etag)) => {
510                write_cache_atomic(&c);
511                if let Some(e) = new_etag {
512                    write_etag(&e);
513                }
514                return Some(c);
515            }
516            None => { /* fetch failed, fall through to stale */ }
517        }
518    }
519
520    // 3) Stale cache is better than nothing.
521    if let Some(c) = read_cache_any() {
522        tracing::debug!("models.dev: using stale cache (live fetch unavailable)");
523        return Some(c);
524    }
525
526    None
527}
528
529/// Result of a conditional GET.
530enum ConditionalResult {
531    /// Server returned 304 — data unchanged.
532    NotModified,
533    /// Server returned 200 — new data + optional new ETag.
534    Updated(MdCatalog, Option<String>),
535}
536
537/// Read the cache only if its mtime is within the mtime window.
538fn read_cache_if_fresh() -> Option<MdCatalog> {
539    let path = cache_path()?;
540    let meta = std::fs::metadata(&path).ok()?;
541    let modified = meta.modified().ok()?;
542    let age = SystemTime::now().duration_since(modified).ok()?;
543    if age > mtime_window() {
544        return None;
545    }
546    read_cache(&path)
547}
548
549/// Read the cache regardless of freshness.
550fn read_cache_any() -> Option<MdCatalog> {
551    let path = cache_path()?;
552    read_cache(&path)
553}
554
555fn read_cache(path: &std::path::Path) -> Option<MdCatalog> {
556    let body = std::fs::read_to_string(path).ok()?;
557    match serde_json::from_str::<MdCatalog>(&body) {
558        Ok(c) => Some(c),
559        Err(e) => {
560            tracing::warn!(error = %e, "models.dev: cache corrupt, ignoring");
561            // Corrupt cache: remove so next run refetches cleanly.
562            let _ = std::fs::remove_file(path);
563            None
564        }
565    }
566}
567
568/// Touch the cache file's mtime to reset the mtime window (after 304).
569fn touch_cache_mtime() {
570    let Some(path) = cache_path() else { return };
571    // Set mtime to now. `set_modified` is stable in Rust 1.75+.
572    let now = std::time::SystemTime::now();
573    let _ = filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(now));
574}
575
576/// Path to the ETag sidecar file.
577fn etag_path() -> Option<PathBuf> {
578    let base = cache_path()?;
579    Some(base.with_extension("json.etag"))
580}
581
582/// Read the stored ETag (if any) for conditional GET.
583fn read_etag() -> Option<String> {
584    let path = etag_path()?;
585    let body = std::fs::read_to_string(&path).ok()?;
586    let trimmed = body.trim();
587    if trimmed.is_empty() {
588        None
589    } else {
590        Some(trimmed.to_string())
591    }
592}
593
594/// Write the ETag sidecar atomically.
595fn write_etag(etag: &str) {
596    let Some(path) = etag_path() else { return };
597    let tmp = path.with_extension("json.etag.tmp");
598    if std::fs::write(&tmp, etag).is_ok() {
599        let _ = std::fs::rename(&tmp, &path);
600    }
601}
602
603/// Remove the ETag sidecar (used when recovering from a stale-ETag state).
604fn clear_etag() {
605    let Some(path) = etag_path() else { return };
606    let _ = std::fs::remove_file(&path);
607}
608
609/// Write the catalog atomically (temp + rename), per AGENTS.md I/O rules.
610fn write_cache_atomic(catalog: &MdCatalog) {
611    let Some(path) = cache_path() else {
612        return;
613    };
614    let Some(parent) = path.parent() else {
615        return;
616    };
617    if std::fs::create_dir_all(parent).is_err() {
618        return;
619    }
620    let Ok(body) = serde_json::to_string(catalog) else {
621        return;
622    };
623    // PID-suffixed temp name avoids concurrent-writer collisions.
624    let tmp = path.with_file_name(format!("models-dev.json.{}.tmp", std::process::id()));
625    if std::fs::write(&tmp, &body).is_err() {
626        return;
627    }
628    if let Err(e) = std::fs::rename(&tmp, &path) {
629        tracing::debug!(error = %e, "models.dev: cache rename failed");
630        let _ = std::fs::remove_file(&tmp);
631    }
632}
633
634/// Live fetch with bounded retries and conditional GET (ETag) support.
635///
636/// - If `etag` is `Some`, sends `If-None-Match` header.
637/// - Returns `NotModified` on 304, `Updated` on 200, `None` on failure.
638async fn live_fetch_conditional(etag: Option<&str>) -> Option<ConditionalResult> {
639    let client = reqwest::Client::builder()
640        .timeout(FETCH_TIMEOUT)
641        .build()
642        .ok()?;
643    let url = format!("{}/api.json", models_url().trim_end_matches('/'));
644
645    for attempt in 0..FETCH_RETRIES {
646        let mut req = client.get(&url).header("User-Agent", USER_AGENT);
647        if let Some(e) = etag {
648            req = req.header("If-None-Match", e);
649        }
650        match req.send().await {
651            Ok(resp) => {
652                let status = resp.status();
653                if status.as_u16() == 304 {
654                    tracing::debug!("models.dev: 304 Not Modified");
655                    return Some(ConditionalResult::NotModified);
656                }
657                if status.is_success() {
658                    // Capture the new ETag (if any) before consuming the body.
659                    let new_etag = resp
660                        .headers()
661                        .get(reqwest::header::ETAG)
662                        .and_then(|v| v.to_str().ok())
663                        .map(|s| s.to_string());
664                    match resp.text().await {
665                        Ok(body) => match serde_json::from_str::<MdCatalog>(&body) {
666                            Ok(c) => {
667                                tracing::debug!(
668                                    models = c.0.values().map(|p| p.models.len()).sum::<usize>(),
669                                    "models.dev: fetched"
670                                );
671                                return Some(ConditionalResult::Updated(c, new_etag));
672                            }
673                            Err(e) => {
674                                tracing::warn!(error = %e, "models.dev: parse failed");
675                                return None;
676                            }
677                        },
678                        Err(e) => {
679                            tracing::warn!(error = %e, "models.dev: body read failed");
680                        }
681                    }
682                } else {
683                    tracing::warn!(status = %status, "models.dev: non-success status");
684                }
685            }
686            Err(e) => {
687                tracing::warn!(error = %e, attempt, "models.dev: fetch failed");
688            }
689        }
690        if attempt + 1 < FETCH_RETRIES {
691            tokio::time::sleep(RETRY_BACKOFF).await;
692        }
693    }
694    None
695}
696
697// ---------------------------------------------------------------------------
698// Tests
699// ---------------------------------------------------------------------------
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn md(
706        provider: &str,
707        model_id: &str,
708        cost: Option<(f64, f64)>,
709        ctx: f64,
710        output: f64,
711        reasoning: bool,
712    ) -> MdCatalog {
713        let mut cat = MdCatalog::default();
714        let m = MdModel {
715            name: model_id.to_string(),
716            family: None,
717            reasoning,
718            tool_call: false,
719            attachment: false,
720            temperature: None,
721            structured_output: None,
722            knowledge: None,
723            release_date: None,
724            last_updated: None,
725            open_weights: None,
726            interleaved: None,
727            reasoning_options: None,
728            limit: MdLimit {
729                context: ctx,
730                input: None,
731                output,
732            },
733            cost: cost.map(|(i, o)| MdCost {
734                input: i,
735                output: o,
736                cache_read: None,
737                cache_write: None,
738                tiers: None,
739                context_over_200k: None,
740                reasoning: None,
741                input_audio: None,
742                output_audio: None,
743            }),
744            modalities: None,
745            status: None,
746            provider: None,
747        };
748        let mut models = BTreeMap::new();
749        models.insert(model_id.to_string(), m);
750        cat.0.insert(
751            provider.to_string(),
752            MdProvider {
753                name: provider.to_string(),
754                env: vec![],
755                npm: None,
756                api: None,
757                doc: None,
758                models,
759            },
760        );
761        cat
762    }
763
764    #[test]
765    fn schema_parses_snapshot() {
766        // Minimal valid api.json shape.
767        let json = r#"{
768            "deepseek": {
769                "id": "deepseek",
770                "name": "DeepSeek",
771                "env": ["DEEPSEEK_API_KEY"],
772                "npm": "@ai-sdk/openai-compatible",
773                "api": "https://api.deepseek.com",
774                "models": {
775                    "deepseek-chat": {
776                        "id": "deepseek-chat",
777                        "name": "DeepSeek Chat",
778                        "release_date": "2025-12-01",
779                        "attachment": true,
780                        "reasoning": false,
781                        "tool_call": true,
782                        "temperature": true,
783                        "limit": { "context": 1000000, "output": 384000 },
784                        "cost": { "input": 0.14, "output": 0.28, "cache_read": 0.0028 }
785                    }
786                }
787            }
788        }"#;
789        let cat: MdCatalog = serde_json::from_str(json).unwrap();
790        let m = &cat.0["deepseek"].models["deepseek-chat"];
791        assert!((m.cost.as_ref().unwrap().input - 0.14).abs() < 1e-9);
792        assert_eq!(m.limit.context, 1000000.0);
793        assert_eq!(m.limit.output, 384000.0);
794    }
795
796    #[test]
797    fn write_cache_roundtrips() {
798        let cat = md(
799            "deepseek",
800            "deepseek-chat",
801            Some((0.14, 0.28)),
802            1000000.0,
803            384000.0,
804            false,
805        );
806        let tmp = std::env::temp_dir().join(format!("oxicode-md-test-{}.json", std::process::id()));
807        let body = serde_json::to_string(&cat).unwrap();
808        std::fs::write(&tmp, &body).unwrap();
809        let back: MdCatalog =
810            serde_json::from_str(&std::fs::read_to_string(&tmp).unwrap()).unwrap();
811        let _ = std::fs::remove_file(&tmp);
812        assert!(back.0.contains_key("deepseek"));
813    }
814}