//! models.dev reference-data sync. //! //! The app carries a compiled-in models.dev snapshot (generated by //! scripts/fetch-models-dev.mjs before every build). This module lets the //! user refresh it at runtime from https://models.dev/api.json without //! rebuilding: the synced copy lives at `~/.kimi-switch/models-dev.json` and //! takes precedence whenever it exists and parses. The dashboard price index //! (see dashboard::models_dev_cost_index) re-checks that file's mtime so new //! prices take effect without an app restart. use std::collections::HashSet; use std::fs; use std::path::PathBuf; use std::time::Duration; use chrono::Utc; use serde::Serialize; use serde_json::Value; use crate::db::kimi_switch_data_dir; /// Compiled-in snapshot — the same file the dashboard price index used to /// embed directly. Always available; the runtime-synced copy (if any) wins. const BUILTIN_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json"); const SOURCE_URL: &str = "https://models.dev/api.json"; const SYNCED_FILE: &str = "models-dev.json"; /// Where the runtime-synced snapshot lives. The dashboard stats this path's /// mtime to decide when to rebuild its price index. pub fn synced_path() -> PathBuf { kimi_switch_data_dir().join(SYNCED_FILE) } /// Read + validate the synced copy. Anything unparsable or missing /// `last_updated` is treated as absent so the built-in snapshot takes over. fn read_synced() -> Option { let raw = fs::read_to_string(synced_path()).ok()?; let v: Value = serde_json::from_str(&raw).ok()?; if v.get("last_updated").and_then(|x| x.as_str()).is_some() { Some(raw) } else { None } } /// The snapshot the app should use right now: the synced copy when present /// and valid, otherwise the compiled-in one. pub fn effective_snapshot() -> String { read_synced().unwrap_or_else(|| BUILTIN_SNAPSHOT.to_string()) } #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ModelsDevStatus { /// "synced" = runtime online copy; "builtin" = compiled at build time. pub source: &'static str, pub last_updated: String, pub model_count: u64, pub provider_count: u64, } /// Count models / providers and read the `last_updated` stamp from a /// snapshot string ("/" keys, plus the `last_updated` meta). fn status_of(source: &'static str, raw: &str) -> ModelsDevStatus { let v: Value = serde_json::from_str(raw).unwrap_or(Value::Null); let mut model_count = 0u64; let mut providers = HashSet::new(); if let Some(obj) = v.as_object() { for (k, entry) in obj { if k == "last_updated" || !entry.is_object() { continue; } model_count += 1; if let Some((p, _)) = k.split_once('/') { providers.insert(p.to_string()); } } } ModelsDevStatus { source, last_updated: v .get("last_updated") .and_then(|x| x.as_str()) .unwrap_or("unknown") .to_string(), model_count, provider_count: providers.len() as u64, } } fn status(source: &'static str) -> ModelsDevStatus { match source { "synced" => { let raw = read_synced().unwrap_or_default(); status_of("synced", &raw) } _ => status_of("builtin", BUILTIN_SNAPSHOT), } } /// Numbers serialize as integers when they are whole (matches the mjs /// script's JSON output, e.g. context 1000000 not 1000000.0). fn num(v: f64) -> Value { if v.fract() == 0.0 && v.abs() < 9e15 { Value::from(v as i64) } else { Value::from(v) } } /// Transform models.dev api.json into the compact snapshot shape produced by /// scripts/fetch-models-dev.mjs (the canonical field-picking logic lives /// there; this is the runtime port). Pure — unit-tested below. fn build_snapshot(raw: &Value) -> Result { let providers = raw.as_object().ok_or("api.json is not a JSON object")?; let mut snapshot = serde_json::Map::new(); snapshot.insert( "last_updated".into(), Value::String(Utc::now().format("%Y-%m-%d").to_string()), ); for (provider_id, provider) in providers { let Some(models) = provider.get("models").and_then(|m| m.as_object()) else { continue; }; for (model_id, m) in models { let mut entry = serde_json::Map::new(); if let Some(name) = m.get("name").and_then(|x| x.as_str()) { entry.insert("name".into(), Value::String(name.to_string())); } // Context 0 means "not applicable" (image/audio models) — treat as // missing so callers fall back to defaults. if let Some(ctx) = m .pointer("/limit/context") .and_then(|x| x.as_f64()) .filter(|c| *c > 0.0) { entry.insert("context".into(), num(ctx)); } for flag in ["reasoning", "tool_call", "structured_output"] { if m.get(flag).and_then(|x| x.as_bool()) == Some(true) { entry.insert(flag.into(), Value::Bool(true)); } } if let Some(input) = m.pointer("/modalities/input").and_then(|x| x.as_array()) { let has = |s: &str| input.iter().any(|v| v.as_str() == Some(s)); if has("image") { entry.insert("image".into(), Value::Bool(true)); } if has("video") { entry.insert("video".into(), Value::Bool(true)); } } if let Some(cost) = m.get("cost").and_then(|c| c.as_object()) { let mut c = serde_json::Map::new(); for k in ["input", "output", "cache_read", "cache_write"] { if let Some(n) = cost.get(k).and_then(|x| x.as_f64()) { c.insert(k.into(), num(n)); } } if !c.is_empty() { entry.insert("cost".into(), Value::Object(c)); } } snapshot.insert(format!("{provider_id}/{model_id}"), Value::Object(entry)); } } Ok(Value::Object(snapshot)) } /// reqwest is built without the default system-proxy feature, so proxies must /// be attached explicitly. Mirror fetch-models-dev.mjs: env vars first, then /// git's http.proxy. fn proxy_url() -> Option { let env = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] .iter() .find_map(|k| std::env::var(k).ok().filter(|p| !p.trim().is_empty())); env.or_else(git_http_proxy) } fn git_http_proxy() -> Option { let out = std::process::Command::new("git") .args(["config", "--get", "http.proxy"]) .output() .ok()?; let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); (!s.is_empty()).then_some(s) } fn http_client() -> Result { let mut b = reqwest::Client::builder() .user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION"))) .timeout(Duration::from_secs(60)); if let Some(p) = proxy_url() { b = b.proxy(reqwest::Proxy::all(&p).map_err(|e| format!("invalid proxy '{p}': {e}"))?); } b.build().map_err(|e| format!("failed to build HTTP client: {e}")) } /// Download api.json, transform it and atomically replace the synced copy. /// On any failure the previous snapshot (synced or built-in) stays untouched. async fn sync_from_remote() -> Result { let client = http_client()?; let raw: Value = client .get(SOURCE_URL) .header("Accept", "application/json") .send() .await .map_err(|e| format!("request failed: {e}"))? .error_for_status() .map_err(|e| format!("models.dev returned {e}"))? .json() .await .map_err(|e| format!("failed to parse api.json: {e}"))?; let snapshot = build_snapshot(&raw)?; let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())? + "\n"; let path = synced_path(); if let Some(dir) = path.parent() { fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; } let tmp = path.with_extension("json.tmp"); fs::write(&tmp, &json).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?; // Windows rename replaces an existing destination (MOVEFILE_REPLACE_EXISTING). fs::rename(&tmp, &path).map_err(|e| { let _ = fs::remove_file(&tmp); format!("failed to replace {}: {e}", path.display()) })?; Ok(status_of("synced", &json)) } /// Refresh the models.dev snapshot from the network. #[tauri::command] pub async fn sync_models_dev() -> Result { sync_from_remote().await } /// Where the current reference data comes from (for the settings card). #[tauri::command] pub fn get_models_dev_status() -> Result { Ok(if read_synced().is_some() { status("synced") } else { status("builtin") }) } /// The synced snapshot's raw JSON, or None when the frontend should keep /// using the bundled static asset. #[tauri::command] pub fn get_models_dev_snapshot() -> Result, String> { Ok(read_synced()) } /// Drop the synced copy and fall back to the compiled-in snapshot. #[tauri::command] pub fn reset_models_dev() -> Result { match fs::remove_file(synced_path()) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(format!("failed to remove synced snapshot: {e}")), } Ok(status("builtin")) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn build_snapshot_picks_reference_fields() { let raw = json!({ "moonshotai": { "models": { "kimi-k3": { "name": "Kimi K3", "limit": { "context": 262144, "output": 16384 }, "reasoning": true, "tool_call": true, "structured_output": true, "modalities": { "input": ["text", "image"], "output": ["text"] }, "cost": { "input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 0.0 } }, "kimi-image": { "name": "Kimi Image", "limit": { "context": 0 }, "modalities": { "input": ["text", "image", "video"] } } } }, "zhipuai": { "models": { "glm-5.3": { "name": "GLM-5.3", "reasoning": false, "cost": { "input": 0.6, "output": 2.2 } } } } }); let snap = build_snapshot(&raw).unwrap(); let obj = snap.as_object().unwrap(); assert!(obj.contains_key("last_updated")); let k3 = &obj["moonshotai/kimi-k3"]; assert_eq!(k3["name"], "Kimi K3"); assert_eq!(k3["context"], 262144); assert_eq!(k3["reasoning"], true); assert_eq!(k3["tool_call"], true); assert_eq!(k3["structured_output"], true); assert_eq!(k3["image"], true); assert!(k3.get("video").is_none()); assert_eq!(k3["cost"]["input"], 3.0); assert_eq!(k3["cost"]["cache_read"], 0.3); assert_eq!(k3["cost"]["cache_write"], 0.0); // Context 0 → dropped; video modality → flagged. let img = &obj["moonshotai/kimi-image"]; assert!(img.get("context").is_none()); assert_eq!(img["video"], true); assert!(img.get("cost").is_none()); // Explicit false is omitted (same as the mjs script). let glm = &obj["zhipuai/glm-5.3"]; assert!(glm.get("reasoning").is_none()); assert_eq!(glm["cost"]["input"], 0.6); } #[test] fn status_counts_models_and_providers() { let raw = r#"{ "last_updated": "2026-09-21", "moonshotai/kimi-k3": { "cost": { "input": 3.0 } }, "moonshotai/kimi-k2": { "name": "K2" }, "zhipuai/glm-5.3": {} }"#; let st = status_of("builtin", raw); assert_eq!(st.last_updated, "2026-09-21"); assert_eq!(st.model_count, 3); assert_eq!(st.provider_count, 2); } #[test] fn num_serializes_whole_numbers_as_integers() { assert_eq!(num(262144.0), json!(262144)); assert_eq!(num(0.3), json!(0.3)); } }