1use 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
52const DEFAULT_MTIME_WINDOW: Duration = Duration::from_secs(60 * 60);
59
60const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
62
63const FETCH_RETRIES: u32 = 2;
65
66const RETRY_BACKOFF: Duration = Duration::from_millis(200);
68
69const DEFAULT_URL: &str = "https://models.dev";
71
72const USER_AGENT: &str = concat!("oxicode/", env!("CARGO_PKG_VERSION"));
74
75#[derive(Debug, Default, Serialize, Deserialize)]
81pub struct MdCatalog(pub BTreeMap<String, MdProvider>);
82
83#[derive(Debug, Serialize, Deserialize)]
85pub struct MdProvider {
86 #[allow(dead_code)]
88 pub name: String,
89 #[allow(dead_code)]
91 pub env: Vec<String>,
92 #[serde(default)]
94 #[allow(dead_code)]
95 pub npm: Option<String>,
96 #[serde(default)]
98 #[allow(dead_code)]
99 pub api: Option<String>,
100 #[serde(default)]
102 #[allow(dead_code)]
103 pub doc: Option<String>,
104 pub models: BTreeMap<String, MdModel>,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
110pub struct MdModel {
111 #[allow(dead_code)]
113 pub name: String,
114 #[serde(default)]
116 #[allow(dead_code)]
117 pub family: Option<String>,
118 pub reasoning: bool,
120 #[serde(default)]
122 pub tool_call: bool,
123 #[serde(default)]
125 pub attachment: bool,
126 #[serde(default)]
128 #[allow(dead_code)]
129 pub temperature: Option<bool>,
130 #[serde(default)]
132 #[allow(dead_code)]
133 pub structured_output: Option<bool>,
134 #[serde(default)]
136 #[allow(dead_code)]
137 pub knowledge: Option<String>,
138 #[serde(default)]
140 #[allow(dead_code)]
141 pub release_date: Option<String>,
142 #[serde(default)]
144 #[allow(dead_code)]
145 pub last_updated: Option<String>,
146 #[serde(default)]
148 #[allow(dead_code)]
149 pub open_weights: Option<bool>,
150 #[serde(default)]
152 #[allow(dead_code)]
153 pub interleaved: Option<serde_json::Value>,
154 #[serde(default)]
156 #[allow(dead_code)]
157 pub reasoning_options: Option<Vec<MdReasoningOption>>,
158 pub limit: MdLimit,
160 #[serde(default)]
162 pub cost: Option<MdCost>,
163 #[serde(default)]
165 #[allow(dead_code)]
166 pub modalities: Option<MdModalities>,
167 #[serde(default)]
169 #[allow(dead_code)]
170 pub status: Option<String>,
171 #[serde(default)]
173 pub provider: Option<MdModelProvider>,
174}
175
176#[derive(Debug, Serialize, Deserialize)]
179pub struct MdModelProvider {
180 #[serde(default)]
182 pub npm: Option<String>,
183 #[serde(default)]
185 pub api: Option<String>,
186}
187
188#[derive(Debug, Serialize, Deserialize)]
190pub struct MdLimit {
191 pub context: f64,
193 #[serde(default)]
195 pub input: Option<f64>,
196 pub output: f64,
198}
199
200#[derive(Debug, Serialize, Deserialize)]
202#[allow(missing_docs)]
203pub struct MdCost {
204 pub input: f64,
206 pub output: f64,
208 #[serde(default)]
210 pub cache_read: Option<f64>,
211 #[serde(default)]
213 pub cache_write: Option<f64>,
214 #[serde(default)]
216 pub tiers: Option<Vec<MdCostTier>>,
217 #[serde(default)]
219 pub context_over_200k: Option<MdCostTierData>,
220 #[serde(default)]
222 pub reasoning: Option<f64>,
223 #[serde(default)]
225 pub input_audio: Option<f64>,
226 #[serde(default)]
228 pub output_audio: Option<f64>,
229}
230
231#[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#[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#[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#[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
290pub 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 _ => (Api::OpenAiCompletions, AuthMethod::Bearer),
312 }
313}
314
315static MODELS_DEV: OnceLock<Option<Arc<MdCatalog>>> = OnceLock::new();
332
333pub 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 let _ = MODELS_DEV.set(arc_opt);
346}
347
348pub fn get() -> Option<&'static MdCatalog> {
354 MODELS_DEV.get().and_then(|o| o.as_deref())
355}
356
357pub 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#[cfg(test)]
394pub fn reset_for_tests() {
395 }
398
399fn 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
416fn 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
427fn 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
435fn models_url() -> String {
437 std::env::var("OXICODE_MODELS_DEV_URL").unwrap_or_else(|_| DEFAULT_URL.to_string())
438}
439
440fn 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
452fn 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
461async fn fetch_with_fallback() -> Option<MdCatalog> {
471 if !enabled() {
472 return None;
473 }
474
475 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 if !fetch_disabled() {
485 let etag = read_etag();
486 match live_fetch_conditional(etag.as_deref()).await {
487 Some(ConditionalResult::NotModified) => {
488 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 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 => { }
517 }
518 }
519
520 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
529enum ConditionalResult {
531 NotModified,
533 Updated(MdCatalog, Option<String>),
535}
536
537fn 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
549fn 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 let _ = std::fs::remove_file(path);
563 None
564 }
565 }
566}
567
568fn touch_cache_mtime() {
570 let Some(path) = cache_path() else { return };
571 let now = std::time::SystemTime::now();
573 let _ = filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(now));
574}
575
576fn etag_path() -> Option<PathBuf> {
578 let base = cache_path()?;
579 Some(base.with_extension("json.etag"))
580}
581
582fn 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
594fn 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
603fn clear_etag() {
605 let Some(path) = etag_path() else { return };
606 let _ = std::fs::remove_file(&path);
607}
608
609fn 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 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
634async 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 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#[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 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}