| 1 | //! models.dev catalog sync — the fix for "a model released after this binary |
| 2 | //! was built never shows up" (e.g. new OpenCode zen models). Mirrors |
| 3 | //! opencode's ModelsDev service (`packages/core/src/models-dev.ts`): |
| 4 | //! |
| 5 | //! - fetch `https://models.dev/api.json` (override via `MODELS_DEV_URL`) |
| 6 | //! - cache it on disk (`$XDG_CACHE_HOME/wynba/models.json`) and treat it as |
| 7 | //! fresh for 5 minutes; a stale cache still serves data while a background |
| 8 | //! thread refetches, so interactive paths never block on the network |
| 9 | //! - only a cold start with no cache pays for a synchronous fetch |
| 10 | |
| 11 | use std::path:: |
| 12 | use std::sync:: |
| 13 | Arc, Mutex |
| 14 | atomic::{AtomicBool |
| 15 | }; |
| 16 | use std::time:: |
| 17 | |
| 18 | use anyhow::Context |
| 19 | use serde_json::Value |
| 20 | |
| 21 | /// How long a fetched catalog counts as fresh (opencode uses 5 minutes too). |
| 22 | const TTL: Duration |
| 23 | |
| 24 | /// Catalog source; override for mirrors and offline testing. |
| 25 | fn source_url() -> String |
| 26 | std::env::var |
| 27 | .ok() |
| 28 | .filter(|s| |
| 29 | .unwrap_or_else(|| "https://models.dev/api.json |
| 30 | } |
| 31 | |
| 32 | /// Disk cache location, mirroring opencode's global-cache layout |
| 33 | /// (`$XDG_CACHE_HOME/<app>/models.json`, defaulting to `~/.cache`). |
| 34 | fn cache_path() -> Option |
| 35 | let base = |
| 36 | .ok() |
| 37 | .filter(|s| |
| 38 | .or_else(|| std |
| 39 | Some(PathBuf |
| 40 | } |
| 41 | |
| 42 | fn fetch_and_write(path: |
| 43 | let resp = |
| 44 | .timeout(Duration::from_secs |
| 45 | .call() |
| 46 | .context("models.dev fetch failed") |
| 47 | let v: Value |
| 48 | if let Some |
| 49 | let _ = |
| 50 | } |
| 51 | // Tempfile + rename so a crash mid-write can't corrupt the cached copy. |
| 52 | let tmp = |
| 53 | std::fs::write |
| 54 | std::fs::rename |
| 55 | Ok(v) |
| 56 | } |
| 57 | |
| 58 | /// One-shot guard so at most one background refetch runs per process. |
| 59 | static REFRESHING: AtomicBool |
| 60 | |
| 61 | /// Process-lifetime memo of the parsed catalog (opencode memoizes forever per |
| 62 | /// process too; the background refresh swaps in the newer parse). Without it, |
| 63 | /// every picker row's display-name lookup would re-read and re-parse a |
| 64 | /// multi-megabyte JSON file. |
| 65 | static CATALOG: Mutex |
| 66 | |
| 67 | /// Load the catalog. A cached copy is returned immediately (fresh or stale); |
| 68 | /// when stale a background thread refetches it for next time. Only a missing |
| 69 | /// cache triggers a synchronous fetch. A corrupt cache falls through to a |
| 70 | /// synchronous fetch so one bad write can't permanently disable the catalog. |
| 71 | pub fn catalog() -> |
| 72 | if let Ok |
| 73 | if let Some |
| 74 | return Some(hit |
| 75 | } |
| 76 | } |
| 77 | let path = |
| 78 | let modified = |
| 79 | .ok() |
| 80 | .and_then(|m| |
| 81 | let fresh = |
| 82 | .and_then(|t| |
| 83 | .is_some_and(|age| |
| 84 | let contents = |
| 85 | // Fast path: fresh cache that parses serves immediately without network. |
| 86 | if fresh { |
| 87 | if let Some |
| 88 | if let Ok |
| 89 | let parsed = |
| 90 | if let Ok |
| 91 | *guard = Some |
| 92 | } |
| 93 | return Some(parsed |
| 94 | } |
| 95 | // Corrupt fresh cache: fall through to a synchronous refetch |
| 96 | // below instead of serving nothing forever. |
| 97 | } |
| 98 | } |
| 99 | if !fresh && |
| 100 | let bg = |
| 101 | std::thread::spawn |
| 102 | // Guard resets the flag even on panic so a panicking refresh |
| 103 | // can't disable background updates for the rest of the process. |
| 104 | struct Reset; |
| 105 | impl Drop for |
| 106 | fn drop(&mut |
| 107 | REFRESHING.store(false, |
| 108 | } |
| 109 | } |
| 110 | let _reset = |
| 111 | let fetch = |
| 112 | match fetch { |
| 113 | Ok(Ok |
| 114 | if let Ok |
| 115 | if let Ok |
| 116 | if let Ok |
| 117 | *guard = Some |
| 118 | } |
| 119 | return; |
| 120 | } |
| 121 | } |
| 122 | crate::events::warn( |
| 123 | "models_dev.refresh_parse_failed", |
| 124 | &[("path", &bg |
| 125 | ); |
| 126 | } |
| 127 | Ok(Err |
| 128 | crate::events::warn( |
| 129 | } |
| 130 | Err(_) |
| 131 | crate::events::warn( |
| 132 | } |
| 133 | } |
| 134 | }); |
| 135 | } |
| 136 | let parsed: Option |
| 137 | Some(c) |
| 138 | Ok(v) |
| 139 | Err(_) |
| 140 | }, |
| 141 | None => fetch_and_write |
| 142 | }; |
| 143 | let parsed = |
| 144 | if let Ok |
| 145 | *guard = Some |
| 146 | } |
| 147 | Some(parsed) |
| 148 | } |
| 149 | |
| 150 | /// Test hook: drop the process-wide memo so env-driven tests start clean. |
| 151 | #[cfg(test)] |
| 152 | #[doc(hidden)] |
| 153 | pub fn reset_for_tests() { |
| 154 | if let Ok |
| 155 | *guard = None |
| 156 | } |
| 157 | REFRESHING.store(false, |
| 158 | } |
| 159 | |
| 160 | /// Model ids published for `provider` in the catalog (deprecated entries are |
| 161 | /// dropped, matching opencode's picker filtering). |
| 162 | pub fn provider_models(provider |
| 163 | provider_models_from(catalog()?.as_ref |
| 164 | } |
| 165 | |
| 166 | fn provider_models_from(cat: |
| 167 | let models = |
| 168 | Some( |
| 169 | models |
| 170 | .iter() |
| 171 | .filter(|(_ |
| 172 | .map(|(id |
| 173 | .collect(), |
| 174 | ) |
| 175 | } |
| 176 | |
| 177 | /// Published input context window (`limit.context`) for one model. |
| 178 | pub fn context_window(provider |
| 179 | context_window_from(catalog()?.as_ref |
| 180 | } |
| 181 | |
| 182 | fn context_window_from(cat: |
| 183 | cat.get(provider |
| 184 | .get("models") |
| 185 | .get(model)? |
| 186 | .get("limit") |
| 187 | .get("context") |
| 188 | .and_then(Value::as_u64 |
| 189 | .and_then(|w| |
| 190 | } |
| 191 | |
| 192 | /// Human-readable model name from the catalog (e.g. `x-preview-f-free` → |
| 193 | /// "Ox Alpha Free (Unlimited)"). Shown next to the raw id in the `/model` |
| 194 | /// picker so models are findable by the names providers advertise, not just |
| 195 | /// their wire ids. |
| 196 | pub fn model_display_name(provider |
| 197 | display_name_from(catalog()?.as_ref |
| 198 | } |
| 199 | |
| 200 | fn display_name_from(cat: |
| 201 | cat.get(provider |
| 202 | .get("models") |
| 203 | .get(model)? |
| 204 | .get("name") |
| 205 | .and_then(Value::as_str |
| 206 | .filter(|s| |
| 207 | .map(|s| |
| 208 | } |
| 209 | |
| 210 | #[cfg(test)] |
| 211 | mod tests { |
| 212 | use super::*; |
| 213 | use serde_json::json; |
| 214 | |
| 215 | fn fixture() -> |
| 216 | json!({ |
| 217 | "opencode": { |
| 218 | "id": "opencode", |
| 219 | "name": "OpenCode Zen", |
| 220 | "models": { |
| 221 | "grok-code": { "id |
| 222 | "old-model": { "id |
| 223 | "brand-new": { "id |
| 224 | } |
| 225 | } |
| 226 | }) |
| 227 | } |
| 228 | |
| 229 | #[test] |
| 230 | fn lists_non_deprecated_models() { |
| 231 | let got = |
| 232 | assert_eq!(got.len |
| 233 | assert!(got.contains |
| 234 | assert!(got.contains |
| 235 | assert!(!got. |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn unknown_provider_is_none_not_panic() { |
| 240 | assert!(provider_models_from( |
| 241 | assert!(context_window_from( |
| 242 | } |
| 243 | |
| 244 | #[test] |
| 245 | fn reads_context_windows_from_limit() { |
| 246 | assert_eq!( |
| 247 | context_window_from(&fixture(), |
| 248 | Some(256_000) |
| 249 | ); |
| 250 | // Missing/zero limit → None so callers keep their own default. |
| 251 | assert_eq!( |
| 252 | context_window_from(&fixture(), |
| 253 | None |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn display_names_come_from_the_name_field() { |
| 259 | assert_eq!( |
| 260 | display_name_from(&fixture(), |
| 261 | Some("Grok Code". |
| 262 | ); |
| 263 | // No name field / unknown model → None (callers show the bare id). |
| 264 | assert_eq!(display_name_from( |
| 265 | assert_eq!(display_name_from( |
| 266 | } |
| 267 | |
| 268 | // Env vars are process-global; serialize the catalog round-trip test like |
| 269 | // the event-log tests serialize their shared STATE. |
| 270 | static ENV_LOCK: std |
| 271 | |
| 272 | #[test] |
| 273 | fn catalog_serves_from_disk_cache_without_network() { |
| 274 | let _guard = |
| 275 | reset_for_tests(); |
| 276 | let dir = |
| 277 | let cache_dir = |
| 278 | std::fs::create_dir_all |
| 279 | let cache = |
| 280 | std::fs::write |
| 281 | let saved_xdg = |
| 282 | let saved_url = |
| 283 | // SAFETY: single-threaded w.r.t. these vars via ENV_LOCK; restored below. |
| 284 | unsafe { |
| 285 | std::env::set_var |
| 286 | // A bogus URL proves the fetch path is never taken when the cache |
| 287 | // is fresh. |
| 288 | std::env::set_var |
| 289 | } |
| 290 | let got = |
| 291 | unsafe { |
| 292 | match saved_xdg { |
| 293 | Some(v) |
| 294 | None => std |
| 295 | } |
| 296 | match saved_url { |
| 297 | Some(v) |
| 298 | None => std |
| 299 | } |
| 300 | } |
| 301 | let _ = |
| 302 | let got = |
| 303 | assert!(got.contains |
| 304 | assert_eq!(context_window( |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | #[ignore = |
| 309 | fn catalog_fetches_live_and_writes_cache() { |
| 310 | let _guard = |
| 311 | reset_for_tests(); |
| 312 | let dir = |
| 313 | std::fs::create_dir_all |
| 314 | let saved_xdg = |
| 315 | unsafe { std::env |
| 316 | let got = |
| 317 | unsafe { |
| 318 | match saved_xdg { |
| 319 | Some(v) |
| 320 | None => std |
| 321 | } |
| 322 | } |
| 323 | let got = |
| 324 | assert!(!got. |
| 325 | assert!( |
| 326 | dir.join("wynba/models.json |
| 327 | "catalog must be cached to disk" |
| 328 | ); |
| 329 | let _ = |
| 330 | } |
| 331 | } |