返回 CodeWhale
models_dev_live.rs
根目录 / crates / tui / src / models_dev_live.rs
1 //! Live Models.dev catalog fetch + secret-free disk cache (#4187).
2 //!
3 //! OpenCode-style producer that:
4 //! - reads a stale/fresh disk cache on startup (never blocks model selection),
5 //! - fetches `https://models.dev/catalog.json` in the background with a bounded
6 //! timeout and explicit user-agent (no credentials),
7 //! - writes the cache atomically via temp file + rename,
8 //! - compiles parsed rows into `CatalogOffering`s and publishes them into
9 //! [`crate::provider_lake`],
10 //! - falls back to the prior cache or the bundled snapshot on any failure.
11 //!
12 //! Override knobs (tests / dogfood):
13 //! - `CODEWHALE_MODELS_DEV_URL` — base URL (appends `/catalog.json`) or a full
14 //! `*.json` URL.
15 //! - `CODEWHALE_MODELS_DEV_PATH` — local file path; skips the network.
16 //! - `CODEWHALE_DISABLE_MODELS_DEV_FETCH` — when truthy, never hits the network.
17
18 use std::path::{Path, PathBuf};
19 use std::sync::RwLock;
20 use std::time::Duration;
21
22 use codewhale_config::catalog::{
23 CatalogSnapshot, base_url_fingerprint, live_offerings_from_models_dev, now_unix,
24 };
25 use codewhale_config::models_dev::{MODELS_DEV_CATALOG_URL, ModelsDevCatalog};
26 use codewhale_config::persistence::atomic_write;
27 use serde::{Deserialize, Serialize};
28
29 /// Default TTL for a live Models.dev snapshot (24h, #4187 / #4114).
30 pub const DEFAULT_MODELS_DEV_TTL_SECS: u64 = 24 * 60 * 60;
31
32 /// Bounded HTTP timeout for the Models.dev fetch.
33 pub const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
34
35 /// Explicit user-agent; no credentials, no session cookies.
36 pub const USER_AGENT: &str = concat!("CodeWhale/", env!("CARGO_PKG_VERSION"), " (+models-dev)");
37
38 /// Filename under the CodeWhale `catalog` state dir.
39 pub const CACHE_FILE: &str = "models-dev-catalog.json";
40
41 /// Env: override Models.dev base URL or full catalog URL.
42 pub const ENV_MODELS_DEV_URL: &str = "CODEWHALE_MODELS_DEV_URL";
43 /// Env: load catalog JSON from a local path (skips network).
44 pub const ENV_MODELS_DEV_PATH: &str = "CODEWHALE_MODELS_DEV_PATH";
45 /// Env: disable network fetch entirely (`1`/`true`/`yes`/`on`).
46 pub const ENV_DISABLE_FETCH: &str = "CODEWHALE_DISABLE_MODELS_DEV_FETCH";
47
48 const CACHE_SCHEMA_VERSION: u32 = 1;
49 /// Largest catalog body accepted from the network, an override file, or the
50 /// disk cache. The public catalog is a few MiB; anything past this is refused
51 /// instead of being read whole into memory.
52 const MAX_CATALOG_BYTES: usize = 32 * 1024 * 1024;
53 /// Clock skew tolerated on a cache timestamp. A `fetched_at` further in the
54 /// future than this is not "age zero": it is untrustworthy, so the cache is
55 /// published as stale and the next refresh fetches.
56 const MAX_CACHE_CLOCK_SKEW_SECS: u64 = 5 * 60;
57
58 /// Provenance / freshness of the Models.dev live layer for UI chips (#4187).
59 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
60 #[serde(rename_all = "snake_case")]
61 pub enum ModelsDevFreshness {
62 /// No live/cache layer; pickers see bundled rows only.
63 #[default]
64 Bundled,
65 /// Live (or disk-cache) rows within TTL.
66 Live,
67 /// Disk-cache / prior live rows past TTL; still visible.
68 Stale,
69 /// Last refresh failed; prior/bundled rows remain available.
70 Failed,
71 }
72
73 /// Quiet status snapshot for UI / `/model refresh` feedback.
74 #[derive(Debug, Clone, PartialEq, Eq, Default)]
75 pub struct ModelsDevStatus {
76 pub freshness: ModelsDevFreshness,
77 pub offering_count: usize,
78 pub fetched_at: Option<u64>,
79 pub source_label: String,
80 pub last_error: Option<String>,
81 }
82
83 static STATUS: RwLock<ModelsDevStatus> = RwLock::new(ModelsDevStatus {
84 freshness: ModelsDevFreshness::Bundled,
85 offering_count: 0,
86 fetched_at: None,
87 source_label: String::new(),
88 last_error: None,
89 });
90
91 #[derive(Debug, Clone, Serialize, Deserialize)]
92 struct PersistedModelsDevCache {
93 schema_version: u32,
94 /// Unix seconds the payload was fetched (or loaded from an override path).
95 fetched_at: u64,
96 /// Fingerprint of the source URL/path this body was fetched from. It scopes
97 /// the on-disk cache; it is deliberately not carried on the published rows,
98 /// which describe a model rather than an endpoint (`ModelsDevLive`).
99 source_fingerprint: String,
100 /// Human-readable source label (URL or `file:…`); never a secret.
101 source_label: String,
102 /// Raw Models.dev catalog JSON body (secret-free by construction).
103 body: String,
104 }
105
106 /// Metadata header for the v2 cache format.
107 ///
108 /// v1 serialized the whole cache as one JSON envelope with the catalog body
109 /// escaped inside it, so loading parsed ~5MB twice (envelope, then body) plus
110 /// a full-body copy on every interactive boot. v2 stores the metadata as a
111 /// single JSON header line followed by the raw catalog body bytes, so boot
112 /// performs exactly one catalog parse and zero body copies.
113 const CACHE_SCHEMA_VERSION_V2: u32 = 2;
114
115 #[derive(Debug, Clone, Serialize, Deserialize)]
116 struct PersistedModelsDevCacheV2 {
117 schema_version: u32,
118 fetched_at: u64,
119 source_fingerprint: String,
120 source_label: String,
121 }
122
123 /// Why a Models.dev refresh did not publish new rows.
124 #[derive(Debug, Clone, PartialEq, Eq)]
125 pub enum ModelsDevRefreshError {
126 Disabled,
127 Network(String),
128 HttpStatus(u16),
129 InvalidResponse(String),
130 EmptyCatalog,
131 Io(String),
132 }
133
134 impl std::fmt::Display for ModelsDevRefreshError {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 Self::Disabled => write!(f, "Models.dev fetch disabled"),
138 Self::Network(msg) => write!(f, "network: {msg}"),
139 Self::HttpStatus(code) => write!(f, "HTTP {code}"),
140 Self::InvalidResponse(msg) => write!(f, "invalid response: {msg}"),
141 Self::EmptyCatalog => write!(f, "empty catalog"),
142 Self::Io(msg) => write!(f, "io: {msg}"),
143 }
144 }
145 }
146
147 /// Resolve the on-disk cache path under the CodeWhale `catalog` state dir.
148 ///
149 /// Under `cfg(test)` this is confined the same way settings and config paths
150 /// are: `resolve_state_dir` lives in `codewhale-config`, which is compiled as a
151 /// plain dependency here and so has no view of this crate's isolated test root.
152 /// Without this shield a test that never asked for the developer's catalog read
153 /// their real `~/.codewhale/catalog` and rendered against whatever models they
154 /// last fetched (#5359).
155 #[must_use]
156 pub fn cache_path() -> Option<PathBuf> {
157 #[cfg(test)]
158 {
159 if !crate::test_support::guarded_environment_provides_state_paths() {
160 return Some(
161 crate::test_support::unsealed_test_state_root()
162 .join("catalog")
163 .join(CACHE_FILE),
164 );
165 }
166 }
167 codewhale_config::resolve_state_dir("catalog")
168 .ok()
169 .map(|dir| dir.join(CACHE_FILE))
170 }
171
172 /// Current quiet status (for UI / slash-command feedback).
173 #[must_use]
174 pub fn status() -> ModelsDevStatus {
175 let current = STATUS.read().map(|guard| guard.clone()).unwrap_or_default();
176 honor_bundled_staleness(
177 current,
178 codewhale_config::catalog::reviewed::bundled_source_fetched_at()
179 .is_none_or(|fetched_at| !within_ttl(fetched_at, now_unix())),
180 )
181 }
182
183 /// A Bundled-only report whose snapshot is itself past TTL reports `Stale`:
184 /// rows stay visible for offline use, but never as a current catalog (#A2).
185 fn honor_bundled_staleness(mut status: ModelsDevStatus, bundled_stale: bool) -> ModelsDevStatus {
186 if status.freshness == ModelsDevFreshness::Bundled && bundled_stale {
187 status.freshness = ModelsDevFreshness::Stale;
188 }
189 status
190 }
191
192 fn set_status(next: ModelsDevStatus) {
193 if let Ok(mut guard) = STATUS.write() {
194 *guard = next;
195 }
196 }
197
198 fn env_truthy(name: &str) -> bool {
199 std::env::var(name)
200 .map(|v| {
201 matches!(
202 v.trim().to_ascii_lowercase().as_str(),
203 "1" | "true" | "yes" | "on"
204 )
205 })
206 .unwrap_or(false)
207 }
208
209 /// Resolve the catalog URL from env override or the Models.dev default.
210 #[must_use]
211 pub fn resolve_catalog_url() -> String {
212 match std::env::var(ENV_MODELS_DEV_URL) {
213 Ok(raw) => {
214 let trimmed = raw.trim();
215 if trimmed.is_empty() {
216 MODELS_DEV_CATALOG_URL.to_string()
217 } else if trimmed.ends_with(".json") {
218 trimmed.to_string()
219 } else {
220 format!("{}/catalog.json", trimmed.trim_end_matches('/'))
221 }
222 }
223 Err(_) => MODELS_DEV_CATALOG_URL.to_string(),
224 }
225 }
226
227 /// Seed ProviderLake from the on-disk Models.dev cache before any picker read.
228 ///
229 /// Missing / corrupt / empty caches are a no-op — bundled rows remain available.
230 /// Stale caches still publish (freshness = Stale) so offline startups keep the
231 /// last-known live rows.
232 pub fn maybe_load_persisted_cache() {
233 let Some(path) = cache_path() else {
234 return;
235 };
236 let Some(cache) = load_cache_file(&path) else {
237 return;
238 };
239 // Live only when the cache is recent by a trustworthy clock *and* came
240 // from the source this process would fetch now. A cache written for
241 // another mirror or override file still publishes (offline startups keep
242 // rows) but as stale, so the next refresh replaces it.
243 let freshness = if within_ttl(cache.fetched_at, now_unix())
244 && cache.source_fingerprint == current_source_fingerprint()
245 {
246 ModelsDevFreshness::Live
247 } else {
248 ModelsDevFreshness::Stale
249 };
250 if let Err(err) = publish_from_body(
251 &cache.body,
252 cache.fetched_at,
253 cache.source_label.as_str(),
254 freshness,
255 ) {
256 tracing::debug!(
257 target: "models_dev_live",
258 error = %err,
259 "persisted Models.dev cache failed to publish; keeping bundled"
260 );
261 }
262 }
263
264 /// Force a refresh: prefer `CODEWHALE_MODELS_DEV_PATH`, else network fetch.
265 ///
266 /// On success, updates the disk cache and ProviderLake. On failure, keeps any
267 /// prior live/bundled rows and records a quiet Failed status.
268 pub async fn refresh(force_network: bool) -> Result<usize, ModelsDevRefreshError> {
269 if let Ok(path) = std::env::var(ENV_MODELS_DEV_PATH) {
270 let trimmed = path.trim();
271 if !trimmed.is_empty() {
272 return refresh_from_path(Path::new(trimmed)).await;
273 }
274 }
275
276 if env_truthy(ENV_DISABLE_FETCH) {
277 mark_failed(ModelsDevRefreshError::Disabled);
278 return Err(ModelsDevRefreshError::Disabled);
279 }
280
281 if !force_network {
282 let current = status();
283 if current.freshness == ModelsDevFreshness::Live
284 && current
285 .fetched_at
286 .is_some_and(|ts| within_ttl(ts, now_unix()))
287 {
288 return Ok(current.offering_count);
289 }
290 }
291
292 let url = resolve_catalog_url();
293 let body = match fetch_catalog_body(&url).await {
294 Ok(body) => body,
295 Err(err) => {
296 mark_failed(err.clone());
297 return Err(err);
298 }
299 };
300 let fetched_at = now_unix();
301 let fingerprint = base_url_fingerprint(&url);
302 let count = publish_from_body(&body, fetched_at, &url, ModelsDevFreshness::Live)?;
303 if let Some(path) = cache_path() {
304 save_cache_file(
305 &path,
306 &PersistedModelsDevCache {
307 schema_version: CACHE_SCHEMA_VERSION,
308 fetched_at,
309 source_fingerprint: fingerprint,
310 source_label: url,
311 body,
312 },
313 )
314 .inspect_err(|error| mark_failed(error.clone()))?;
315 }
316 Ok(count)
317 }
318
319 /// Whether a payload fetched at `fetched_at` is still inside the TTL at
320 /// `now`. A timestamp beyond the tolerated clock skew is never fresh: a
321 /// saturating age would read it as zero and suppress refresh until the clock
322 /// caught up.
323 fn within_ttl(fetched_at: u64, now: u64) -> bool {
324 if fetched_at > now.saturating_add(MAX_CACHE_CLOCK_SKEW_SECS) {
325 return false;
326 }
327 now.saturating_sub(fetched_at) <= DEFAULT_MODELS_DEV_TTL_SECS
328 }
329
330 /// Fingerprint of the source a refresh would read right now: the override
331 /// file when `CODEWHALE_MODELS_DEV_PATH` is set, else the catalog URL. Same
332 /// derivation `refresh` stores in the cache.
333 fn current_source_fingerprint() -> String {
334 match std::env::var(ENV_MODELS_DEV_PATH) {
335 Ok(path) if !path.trim().is_empty() => {
336 base_url_fingerprint(&format!("file:{}", Path::new(path.trim()).display()))
337 }
338 _ => base_url_fingerprint(&resolve_catalog_url()),
339 }
340 }
341
342 /// Best-effort background refresh: never panics, never blocks callers.
343 pub fn spawn_background_refresh() {
344 if env_truthy(ENV_DISABLE_FETCH) && std::env::var(ENV_MODELS_DEV_PATH).is_err() {
345 return;
346 }
347 tokio::spawn(async {
348 match refresh(false).await {
349 Ok(count) => {
350 tracing::debug!(
351 target: "models_dev_live",
352 offering_count = count,
353 "Models.dev live catalog refreshed"
354 );
355 }
356 Err(err) => {
357 tracing::debug!(
358 target: "models_dev_live",
359 error = %err,
360 "Models.dev live catalog refresh skipped"
361 );
362 }
363 }
364 });
365 }
366
367 async fn refresh_from_path(path: &Path) -> Result<usize, ModelsDevRefreshError> {
368 let body = match read_catalog_file(path).await {
369 Ok(body) => body,
370 Err(mapped) => {
371 mark_failed(mapped.clone());
372 return Err(mapped);
373 }
374 };
375 let fetched_at = now_unix();
376 let label = format!("file:{}", path.display());
377 let fingerprint = base_url_fingerprint(&label);
378 let count = publish_from_body(&body, fetched_at, &label, ModelsDevFreshness::Live)?;
379 if let Some(cache) = cache_path() {
380 save_cache_file(
381 &cache,
382 &PersistedModelsDevCache {
383 schema_version: CACHE_SCHEMA_VERSION,
384 fetched_at,
385 source_fingerprint: fingerprint,
386 source_label: label,
387 body,
388 },
389 )
390 .inspect_err(|error| mark_failed(error.clone()))?;
391 }
392 Ok(count)
393 }
394
395 async fn fetch_catalog_body(url: &str) -> Result<String, ModelsDevRefreshError> {
396 let client = crate::tls::reqwest_client_builder()
397 .timeout(FETCH_TIMEOUT)
398 .connect_timeout(Duration::from_secs(10))
399 .user_agent(USER_AGENT)
400 .build()
401 .map_err(|err| ModelsDevRefreshError::Network(err.to_string()))?;
402
403 let response = client
404 .get(url)
405 .send()
406 .await
407 .map_err(|err| ModelsDevRefreshError::Network(err.to_string()))?;
408
409 let status = response.status();
410 if !status.is_success() {
411 return Err(ModelsDevRefreshError::HttpStatus(status.as_u16()));
412 }
413
414 read_catalog_response(response, MAX_CATALOG_BYTES).await
415 }
416
417 fn oversized_catalog(limit: usize) -> ModelsDevRefreshError {
418 ModelsDevRefreshError::InvalidResponse(format!("catalog exceeds {limit} bytes"))
419 }
420
421 /// Read a catalog response body without ever holding more than `limit` bytes.
422 async fn read_catalog_response(
423 mut response: reqwest::Response,
424 limit: usize,
425 ) -> Result<String, ModelsDevRefreshError> {
426 if response
427 .content_length()
428 .is_some_and(|length| length > limit as u64)
429 {
430 return Err(oversized_catalog(limit));
431 }
432 let mut bytes = Vec::new();
433 while let Some(chunk) = response
434 .chunk()
435 .await
436 .map_err(|err| ModelsDevRefreshError::Network(err.to_string()))?
437 {
438 if bytes.len().saturating_add(chunk.len()) > limit {
439 return Err(oversized_catalog(limit));
440 }
441 bytes.extend_from_slice(&chunk);
442 }
443 String::from_utf8(bytes)
444 .map_err(|_| ModelsDevRefreshError::InvalidResponse("catalog is not UTF-8".into()))
445 }
446
447 /// Read an override catalog file, refusing one larger than the catalog limit.
448 async fn read_catalog_file(path: &Path) -> Result<String, ModelsDevRefreshError> {
449 use tokio::io::AsyncReadExt as _;
450
451 let file = tokio::fs::File::open(path)
452 .await
453 .map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?;
454 let mut bytes = Vec::new();
455 file.take(MAX_CATALOG_BYTES as u64 + 1)
456 .read_to_end(&mut bytes)
457 .await
458 .map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?;
459 if bytes.len() > MAX_CATALOG_BYTES {
460 return Err(oversized_catalog(MAX_CATALOG_BYTES));
461 }
462 String::from_utf8(bytes)
463 .map_err(|_| ModelsDevRefreshError::InvalidResponse("catalog is not UTF-8".into()))
464 }
465
466 fn publish_from_body(
467 body: &str,
468 fetched_at: u64,
469 source_label: &str,
470 freshness: ModelsDevFreshness,
471 ) -> Result<usize, ModelsDevRefreshError> {
472 let catalog = ModelsDevCatalog::parse_json(body).map_err(|err| {
473 let mapped = ModelsDevRefreshError::InvalidResponse(err.to_string());
474 mark_failed(mapped.clone());
475 mapped
476 })?;
477 // The source fingerprint scopes the *disk cache*, not the rows: a
478 // models.dev row describes a model, not an endpoint (`ModelsDevLive`).
479 let offerings = live_offerings_from_models_dev(&catalog, fetched_at);
480 if offerings.is_empty() {
481 let err = ModelsDevRefreshError::EmptyCatalog;
482 mark_failed(err.clone());
483 return Err(err);
484 }
485 let count = offerings.len();
486 crate::provider_lake::set_live_snapshot(
487 CatalogSnapshot { offerings },
488 crate::provider_lake::LiveSource::ModelsDev,
489 );
490 set_status(ModelsDevStatus {
491 freshness,
492 offering_count: count,
493 fetched_at: Some(fetched_at),
494 source_label: source_label.to_string(),
495 last_error: None,
496 });
497 Ok(count)
498 }
499
500 fn mark_failed(err: ModelsDevRefreshError) {
501 let mut next = status();
502 // Keep prior offering_count / fetched_at so UI can still show the last
503 // rows, but mark the last refresh outcome distinctly from TTL staleness.
504 next.freshness = ModelsDevFreshness::Failed;
505 next.last_error = Some(err.to_string());
506 set_status(next);
507 }
508
509 /// Load the on-disk Models.dev cache.
510 ///
511 /// Reads v2 (single-parse: one header line + raw body) and v1 (JSON envelope
512 /// with an escaped body) formats. Returns metadata and the *unescaped* body
513 /// without copying it in the v2 path.
514 fn load_cache_file(path: &Path) -> Option<PersistedModelsDevCache> {
515 use std::io::Read as _;
516
517 // Header line plus body; a larger file is not ours to trust or to read
518 // whole, so the bundled rows stand instead.
519 const MAX_CACHE_FILE_BYTES: u64 = MAX_CATALOG_BYTES as u64 + 64 * 1024;
520 let mut bytes = Vec::new();
521 std::fs::File::open(path)
522 .ok()?
523 .take(MAX_CACHE_FILE_BYTES + 1)
524 .read_to_end(&mut bytes)
525 .ok()?;
526 if bytes.len() as u64 > MAX_CACHE_FILE_BYTES {
527 return None;
528 }
529 // v2: single-line JSON header terminated by a newline, then the verbatim
530 // catalog body. One small parse, zero body copies.
531 if bytes.first() == Some(&b'{') && bytes.contains(&b'\n') {
532 let split = bytes.iter().position(|b| *b == b'\n')?;
533 if let Ok(header) = serde_json::from_slice::<PersistedModelsDevCacheV2>(&bytes[..split])
534 && header.schema_version == CACHE_SCHEMA_VERSION_V2
535 && !bytes[split + 1..].is_empty()
536 {
537 let body = String::from_utf8(bytes[split + 1..].to_vec()).ok()?;
538 return Some(PersistedModelsDevCache {
539 schema_version: CACHE_SCHEMA_VERSION,
540 fetched_at: header.fetched_at,
541 source_fingerprint: header.source_fingerprint,
542 source_label: header.source_label,
543 body,
544 });
545 }
546 }
547 // v1 fallback: whole-file JSON envelope with the body escaped inside.
548 let cache: PersistedModelsDevCache = serde_json::from_slice(&bytes).ok()?;
549 if cache.schema_version != CACHE_SCHEMA_VERSION {
550 return None;
551 }
552 if cache.body.trim().is_empty() {
553 return None;
554 }
555 Some(cache)
556 }
557
558 fn save_cache_file(
559 path: &Path,
560 cache: &PersistedModelsDevCache,
561 ) -> Result<(), ModelsDevRefreshError> {
562 // Write the single-parse format so the next boot parses the catalog once.
563 let header = PersistedModelsDevCacheV2 {
564 schema_version: CACHE_SCHEMA_VERSION_V2,
565 fetched_at: cache.fetched_at,
566 source_fingerprint: cache.source_fingerprint.clone(),
567 source_label: cache.source_label.clone(),
568 };
569 let mut header_line =
570 serde_json::to_vec(&header).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?;
571 header_line.push(b'\n');
572 let mut payload = header_line;
573 payload.extend_from_slice(cache.body.as_bytes());
574 atomic_write(path, &payload).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))
575 }
576
577 /// Compile helper exposed for unit tests: body → live offerings with normalized
578 /// provider ids.
579 #[cfg(test)]
580 pub(crate) fn offerings_from_json_for_test(
581 body: &str,
582 ) -> Result<Vec<codewhale_config::catalog::CatalogOffering>, String> {
583 let catalog = ModelsDevCatalog::parse_json(body).map_err(|e| e.to_string())?;
584 Ok(live_offerings_from_models_dev(&catalog, 1_700_000_000))
585 }
586
587 #[cfg(test)]
588 mod tests {
589 use super::*;
590 use crate::config::ProviderKind;
591 use crate::provider_lake::{
592 all_catalog_models_for_provider, clear_live_snapshot, lock_live_snapshot,
593 };
594 use crate::test_support::{EnvVarGuard, lock_test_env};
595 use codewhale_config::catalog::CatalogSource;
596
597 const FIXTURE: &str = r#"{
598 "models": {},
599 "providers": {
600 "togetherai": {
601 "id": "togetherai",
602 "models": {
603 "deepseek-ai/DeepSeek-V4-Pro": {
604 "id": "deepseek-ai/DeepSeek-V4-Pro",
605 "name": "DeepSeek V4 Pro",
606 "modalities": { "input": ["text"], "output": ["text"] },
607 "limit": { "context": 128000, "output": 8192 }
608 }
609 }
610 },
611 "moonshotai": {
612 "id": "moonshotai",
613 "models": {
614 "kimi-k2.5": {
615 "id": "kimi-k2.5",
616 "name": "Kimi K2.5",
617 "modalities": { "input": ["text"], "output": ["text"] },
618 "limit": { "context": 256000, "output": 8192 }
619 }
620 }
621 },
622 "unknown-gateway": {
623 "id": "unknown-gateway",
624 "models": {
625 "mystery-1": {
626 "id": "mystery-1",
627 "modalities": { "input": ["text"], "output": ["text"] }
628 }
629 }
630 }
631 }
632 }"#;
633
634 /// An unguarded test must not resolve the developer's catalog cache.
635 ///
636 /// `resolve_state_dir` lives in `codewhale-config`, which is a plain
637 /// dependency here and cannot see this crate's isolated test root, so this
638 /// path had no equivalent of the settings confinement (#5359). A picker
639 /// test then rendered against whatever models the developer last fetched.
640 #[test]
641 fn unguarded_cache_path_stays_inside_the_isolated_test_root() {
642 let path = cache_path().expect("cache path");
643 let isolated = crate::test_support::isolated_test_state_root();
644 assert!(
645 path.starts_with(isolated),
646 "catalog cache escaped the isolated test root: {}",
647 path.display()
648 );
649 assert_eq!(path.file_name().and_then(|n| n.to_str()), Some(CACHE_FILE));
650 }
651
652 /// A test that does seal the environment still resolves it, so the
653 /// confinement above cannot silently break the guarded callers.
654 #[test]
655 fn guarded_cache_path_follows_the_sealed_home() {
656 let _lock = lock_test_env();
657 let home = tempfile::tempdir().expect("tempdir");
658 let _guard = EnvVarGuard::set("CODEWHALE_HOME", home.path());
659
660 let path = cache_path().expect("cache path");
661
662 assert!(
663 path.starts_with(home.path()),
664 "sealed CODEWHALE_HOME was ignored: {}",
665 path.display()
666 );
667 }
668
669 #[test]
670 fn resolve_catalog_url_defaults_and_overrides() {
671 let _lock = lock_test_env();
672 let _url = EnvVarGuard::remove(ENV_MODELS_DEV_URL);
673 assert_eq!(resolve_catalog_url(), MODELS_DEV_CATALOG_URL);
674
675 let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "https://example.test");
676 assert_eq!(resolve_catalog_url(), "https://example.test/catalog.json");
677
678 let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "https://example.test/api.json");
679 assert_eq!(resolve_catalog_url(), "https://example.test/api.json");
680 }
681
682 #[test]
683 fn live_offerings_normalize_models_dev_provider_ids() {
684 let rows = offerings_from_json_for_test(FIXTURE).expect("fixture");
685 let providers: Vec<_> = rows.iter().map(|r| r.provider.as_str()).collect();
686 assert!(providers.contains(&"together"));
687 assert!(providers.contains(&"moonshot"));
688 assert!(providers.contains(&"unknown-gateway"));
689 assert!(!providers.contains(&"togetherai"));
690 assert!(!providers.contains(&"moonshotai"));
691 // Layer 10, not layer 20: a refresh of a public catalog is external
692 // enrichment about a model, not a provider's answer about an endpoint,
693 // so it stays correctable by the signed layer above it.
694 assert!(
695 rows.iter()
696 .all(|r| matches!(r.source, CatalogSource::ModelsDevLive { .. }))
697 );
698 }
699
700 #[test]
701 fn publish_from_path_updates_provider_lake() {
702 let _lock = lock_test_env();
703 let _live = lock_live_snapshot();
704 clear_live_snapshot();
705 let dir = tempfile::tempdir().expect("tempdir");
706 let path = dir.path().join("catalog.json");
707 std::fs::write(&path, FIXTURE).expect("write fixture");
708
709 let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
710 let _disable = EnvVarGuard::set(ENV_DISABLE_FETCH, "1");
711 let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
712
713 let rt = tokio::runtime::Builder::new_current_thread()
714 .enable_all()
715 .build()
716 .expect("runtime");
717 let count = rt.block_on(refresh(true)).expect("refresh from path");
718 assert!(count >= 2);
719
720 let together = all_catalog_models_for_provider(ProviderKind::Together);
721 assert!(
722 together.iter().any(|m| m == "deepseek-ai/DeepSeek-V4-Pro"),
723 "Together lake missing live Models.dev row: {together:?}"
724 );
725 let moonshot = all_catalog_models_for_provider(ProviderKind::Moonshot);
726 assert!(
727 moonshot.iter().any(|m| m == "kimi-k2.5"),
728 "Moonshot lake missing live Models.dev row: {moonshot:?}"
729 );
730
731 let st = status();
732 assert_eq!(st.freshness, ModelsDevFreshness::Live);
733 assert!(st.last_error.is_none());
734 assert!(st.offering_count >= 2);
735
736 // Cache file should exist and be secret-free.
737 let cache = cache_path().expect("cache path");
738 assert!(cache.exists());
739 let on_disk = std::fs::read_to_string(&cache).expect("read cache");
740 let lowered = on_disk.to_lowercase();
741 for needle in ["api_key", "authorization", "bearer", "password"] {
742 assert!(
743 !lowered.contains(&format!("\"{needle}\"")),
744 "cache must not persist `{needle}`"
745 );
746 }
747
748 clear_live_snapshot();
749 }
750
751 #[test]
752 fn bundled_staleness_flips_only_bundled_reports() {
753 let bundled = ModelsDevStatus::default();
754 assert_eq!(bundled.freshness, ModelsDevFreshness::Bundled);
755 assert_eq!(
756 honor_bundled_staleness(bundled.clone(), true).freshness,
757 ModelsDevFreshness::Stale
758 );
759 assert_eq!(
760 honor_bundled_staleness(bundled, false).freshness,
761 ModelsDevFreshness::Bundled
762 );
763 let live = ModelsDevStatus {
764 freshness: ModelsDevFreshness::Live,
765 ..ModelsDevStatus::default()
766 };
767 assert_eq!(
768 honor_bundled_staleness(live, true).freshness,
769 ModelsDevFreshness::Live
770 );
771 }
772
773 #[test]
774 fn invalid_json_keeps_bundled_and_marks_failed() {
775 let _lock = lock_test_env();
776 let _live = lock_live_snapshot();
777 clear_live_snapshot();
778 let dir = tempfile::tempdir().expect("tempdir");
779 let path = dir.path().join("bad.json");
780 std::fs::write(&path, "{not-json").expect("write");
781
782 let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
783 let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
784
785 let before = all_catalog_models_for_provider(ProviderKind::Together);
786 assert!(!before.is_empty(), "bundled Together rows required");
787
788 let rt = tokio::runtime::Builder::new_current_thread()
789 .enable_all()
790 .build()
791 .expect("runtime");
792 let err = rt.block_on(refresh(true)).expect_err("bad json");
793 assert!(matches!(err, ModelsDevRefreshError::InvalidResponse(_)));
794
795 let after = all_catalog_models_for_provider(ProviderKind::Together);
796 assert_eq!(after, before, "bundled rows must survive parse failure");
797 let st = status();
798 assert_eq!(st.freshness, ModelsDevFreshness::Failed);
799 assert!(st.last_error.is_some());
800 clear_live_snapshot();
801 }
802
803 #[test]
804 fn stale_disk_cache_still_publishes() {
805 let _lock = lock_test_env();
806 let _live = lock_live_snapshot();
807 clear_live_snapshot();
808 let dir = tempfile::tempdir().expect("tempdir");
809 let home = dir.path().join("home");
810 let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
811
812 let cache_dir = home.join("catalog");
813 std::fs::create_dir_all(&cache_dir).expect("mkdir");
814 let cache = cache_dir.join(CACHE_FILE);
815 let stale = PersistedModelsDevCache {
816 schema_version: CACHE_SCHEMA_VERSION,
817 fetched_at: 1, // far in the past → stale
818 source_fingerprint: "stale-fp".into(),
819 source_label: "https://models.dev/catalog.json".into(),
820 body: FIXTURE.into(),
821 };
822 save_cache_file(&cache, &stale).expect("save");
823
824 maybe_load_persisted_cache();
825 let st = status();
826 assert_eq!(st.freshness, ModelsDevFreshness::Stale);
827 assert!(st.offering_count >= 2);
828 let together = all_catalog_models_for_provider(ProviderKind::Together);
829 assert!(together.iter().any(|m| m == "deepseek-ai/DeepSeek-V4-Pro"));
830 clear_live_snapshot();
831 }
832
833 #[test]
834 fn a_future_or_foreign_cache_is_never_live() {
835 let now = 2_000_000_000;
836 assert!(within_ttl(now - 60, now));
837 assert!(within_ttl(now + 30, now), "small skew is tolerated");
838 assert!(
839 !within_ttl(now + 3 * 24 * 60 * 60, now),
840 "a future timestamp is not age zero"
841 );
842 assert!(!within_ttl(now - DEFAULT_MODELS_DEV_TTL_SECS - 1, now));
843
844 let _lock = lock_test_env();
845 let _live = lock_live_snapshot();
846 clear_live_snapshot();
847 let dir = tempfile::tempdir().expect("tempdir");
848 let home = dir.path().join("home");
849 let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
850 let _path = EnvVarGuard::remove(ENV_MODELS_DEV_PATH);
851 let _url = EnvVarGuard::remove(ENV_MODELS_DEV_URL);
852 let cache_dir = home.join("catalog");
853 std::fs::create_dir_all(&cache_dir).expect("mkdir");
854 let cache = cache_dir.join(CACHE_FILE);
855
856 // Fresh by the clock, but fetched from another source.
857 let foreign = PersistedModelsDevCache {
858 schema_version: CACHE_SCHEMA_VERSION,
859 fetched_at: now_unix(),
860 source_fingerprint: base_url_fingerprint("https://mirror.example/catalog.json"),
861 source_label: "https://mirror.example/catalog.json".into(),
862 body: FIXTURE.into(),
863 };
864 save_cache_file(&cache, &foreign).expect("save");
865 maybe_load_persisted_cache();
866 assert_eq!(status().freshness, ModelsDevFreshness::Stale);
867
868 // From the current source, but stamped far in the future.
869 let future = PersistedModelsDevCache {
870 fetched_at: now_unix() + 30 * 24 * 60 * 60,
871 source_fingerprint: base_url_fingerprint(MODELS_DEV_CATALOG_URL),
872 source_label: MODELS_DEV_CATALOG_URL.into(),
873 ..foreign.clone()
874 };
875 save_cache_file(&cache, &future).expect("save");
876 maybe_load_persisted_cache();
877 assert_eq!(status().freshness, ModelsDevFreshness::Stale);
878
879 // The same cache stamped now is live.
880 let current = PersistedModelsDevCache {
881 fetched_at: now_unix(),
882 ..future
883 };
884 save_cache_file(&cache, &current).expect("save");
885 maybe_load_persisted_cache();
886 assert_eq!(status().freshness, ModelsDevFreshness::Live);
887 clear_live_snapshot();
888 }
889
890 #[test]
891 fn an_oversized_override_catalog_is_refused() {
892 let dir = tempfile::tempdir().expect("tempdir");
893 let path = dir.path().join("huge.json");
894 let file = std::fs::File::create(&path).expect("create");
895 file.set_len(MAX_CATALOG_BYTES as u64 + 1)
896 .expect("sparse size");
897 let rt = tokio::runtime::Builder::new_current_thread()
898 .enable_all()
899 .build()
900 .expect("runtime");
901 let error = rt
902 .block_on(read_catalog_file(&path))
903 .expect_err("oversized catalog must be refused");
904 assert!(error.to_string().contains("exceeds"), "{error}");
905 }
906
907 #[test]
908 fn network_failure_keeps_prior_rows_and_marks_failed() {
909 let _lock = lock_test_env();
910 let _live = lock_live_snapshot();
911 clear_live_snapshot();
912 let dir = tempfile::tempdir().expect("tempdir");
913 let path = dir.path().join("catalog.json");
914 std::fs::write(&path, FIXTURE).expect("write");
915
916 let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path().join("home"));
917 let _path = EnvVarGuard::set(ENV_MODELS_DEV_PATH, &path);
918
919 let rt = tokio::runtime::Builder::new_current_thread()
920 .enable_all()
921 .build()
922 .expect("runtime");
923 let count = rt.block_on(refresh(true)).expect("seed from path");
924 assert!(count >= 2);
925
926 // Point at a dead URL and force network (clear path override).
927 let _path = EnvVarGuard::remove(ENV_MODELS_DEV_PATH);
928 let _disable = EnvVarGuard::remove(ENV_DISABLE_FETCH);
929 let _url = EnvVarGuard::set(ENV_MODELS_DEV_URL, "http://127.0.0.1:1");
930
931 let err = rt.block_on(refresh(true)).expect_err("dead URL");
932 assert!(matches!(err, ModelsDevRefreshError::Network(_)));
933
934 let together = all_catalog_models_for_provider(ProviderKind::Together);
935 assert!(
936 together.iter().any(|m| m == "deepseek-ai/DeepSeek-V4-Pro"),
937 "prior live rows must survive network failure"
938 );
939 let st = status();
940 assert_eq!(st.freshness, ModelsDevFreshness::Failed);
941 assert!(st.last_error.is_some());
942 assert!(
943 st.offering_count >= 2,
944 "status should retain prior live row count after failure"
945 );
946 clear_live_snapshot();
947 }
948 }
949
949 lines RUST