返回 CodeWhale
model_catalog.rs
根目录 / crates / models / src / model_catalog.rs
1 //! Legacy offline model metadata compatibility catalog (#3072).
2 //!
3 //! This module adds a secret-free metadata layer in front of the legacy model
4 //! tables. It is intentionally conservative: startup reads a local cache plus a
5 //! bundled snapshot, never performs a network refresh, and only overrides a
6 //! legacy fact when the active catalog entry actually carries that field.
7 //! The unscoped `catalog/openrouter.json` file cannot safely own a live provider
8 //! roster; new provider/base-URL-scoped refreshes and runtime consumers use
9 //! `provider_catalog_live` + `provider_lake`. Keep this reader only until the
10 //! remaining `models`, `pricing`, and safe-label compatibility callers migrate.
11
12 use std::collections::BTreeMap;
13 use std::path::PathBuf;
14 use std::sync::{OnceLock, RwLock};
15
16 use anyhow::Result;
17 use chrono::{DateTime, Duration, Utc};
18 use serde::{Deserialize, Serialize};
19
20 const BUNDLED_CATALOG_JSON: &str = include_str!("../assets/model_catalog.bundled.json");
21 const OPENROUTER_CACHE_FILE: &str = "openrouter.json";
22
23 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24 #[serde(rename_all = "snake_case")]
25 pub enum MetadataProvenance {
26 ProviderApi,
27 Bundled,
28 UserOverride,
29 #[default]
30 Unknown,
31 }
32
33 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34 pub struct CatalogEntry {
35 pub id: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub context_window: Option<u32>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub max_output: Option<u32>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub supports_reasoning: Option<bool>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub input_usd_per_million: Option<f64>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub output_usd_per_million: Option<f64>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub modalities: Vec<String>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub supported_parameters: Vec<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub provider_model_id: Option<String>,
52 #[serde(default)]
53 pub provenance: MetadataProvenance,
54 }
55
56 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57 pub struct CatalogCache {
58 pub schema_version: u32,
59 pub source: String,
60 pub fetched_at: DateTime<Utc>,
61 pub ttl_secs: u64,
62 #[serde(default)]
63 pub entries: BTreeMap<String, CatalogEntry>,
64 }
65
66 impl CatalogCache {
67 #[must_use]
68 pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
69 if now <= self.fetched_at {
70 return false;
71 }
72 let ttl = Duration::seconds(self.ttl_secs.min(i64::MAX as u64) as i64);
73 now.signed_duration_since(self.fetched_at) > ttl
74 }
75 }
76
77 #[derive(Debug, Clone)]
78 pub struct MergedCatalog {
79 user_overrides: BTreeMap<String, CatalogEntry>,
80 provider_cache: Option<CatalogCache>,
81 bundled: CatalogCache,
82 now: DateTime<Utc>,
83 }
84
85 impl MergedCatalog {
86 pub fn from_sources(
87 user_overrides: BTreeMap<String, CatalogEntry>,
88 provider_cache: Option<CatalogCache>,
89 bundled: CatalogCache,
90 now: DateTime<Utc>,
91 ) -> Self {
92 Self {
93 user_overrides,
94 provider_cache,
95 bundled,
96 now,
97 }
98 }
99
100 #[must_use]
101 pub(crate) fn resolve(&self, model: &str) -> Option<&CatalogEntry> {
102 if let Some(entry) = entry_for(&self.user_overrides, model) {
103 return Some(entry);
104 }
105 if let Some(provider_cache) = self
106 .provider_cache
107 .as_ref()
108 .filter(|cache| !cache.is_stale(self.now))
109 && let Some(entry) = entry_for(&provider_cache.entries, model)
110 {
111 return Some(entry);
112 }
113 entry_for(&self.bundled.entries, model)
114 }
115
116 /// The offline snapshot is past its own TTL. Rows still resolve — an
117 /// offline fallback that bricks offline is worse than a stale list — but
118 /// pickers must not present it as a current catalog.
119 pub(crate) fn bundled_stale(&self) -> bool {
120 self.bundled.is_stale(self.now)
121 }
122 }
123
124 fn entry_for<'a>(
125 entries: &'a BTreeMap<String, CatalogEntry>,
126 model: &str,
127 ) -> Option<&'a CatalogEntry> {
128 entries.get(model).or_else(|| {
129 let lower = model.to_lowercase();
130 (lower != model).then(|| entries.get(&lower)).flatten()
131 })
132 }
133
134 fn active_catalog() -> &'static RwLock<MergedCatalog> {
135 static ACTIVE: OnceLock<RwLock<MergedCatalog>> = OnceLock::new();
136 ACTIVE.get_or_init(|| {
137 RwLock::new(MergedCatalog::from_sources(
138 BTreeMap::new(),
139 load_cached(),
140 bundled_catalog(),
141 Utc::now(),
142 ))
143 })
144 }
145
146 #[must_use]
147 pub fn resolved_entry(model: &str) -> Option<CatalogEntry> {
148 active_catalog()
149 .read()
150 .ok()
151 .and_then(|catalog| catalog.resolve(model).cloned())
152 }
153
154 #[must_use]
155 pub fn resolved_context_window(model: &str) -> Option<u32> {
156 resolved_entry(model).and_then(|entry| entry.context_window)
157 }
158
159 #[must_use]
160 pub fn resolved_max_output(model: &str) -> Option<u32> {
161 resolved_entry(model).and_then(|entry| entry.max_output)
162 }
163
164 #[must_use]
165 pub fn resolved_supports_reasoning(model: &str) -> Option<bool> {
166 resolved_entry(model).and_then(|entry| entry.supports_reasoning)
167 }
168
169 #[must_use]
170 #[cfg_attr(test, allow(dead_code))]
171 pub fn resolved_usd_pricing(model: &str) -> Option<(f64, f64)> {
172 let entry = resolved_entry(model)?;
173 Some((entry.input_usd_per_million?, entry.output_usd_per_million?))
174 }
175
176 pub fn bundled_catalog() -> CatalogCache {
177 serde_json::from_str(BUNDLED_CATALOG_JSON).expect("bundled model catalog must parse")
178 }
179
180 /// Whether the bundled offline snapshot is past its TTL right now.
181 #[must_use]
182 #[cfg_attr(test, allow(dead_code))]
183 pub fn bundled_catalog_is_stale() -> bool {
184 active_catalog()
185 .read()
186 .map(|catalog| catalog.bundled_stale())
187 .unwrap_or(true)
188 }
189
190 fn catalog_cache_read_path() -> Result<PathBuf> {
191 Ok(codewhale_config::resolve_state_dir("catalog")?.join(OPENROUTER_CACHE_FILE))
192 }
193
194 pub fn load_cached() -> Option<CatalogCache> {
195 let path = catalog_cache_read_path().ok()?;
196 let raw = std::fs::read_to_string(path).ok()?;
197 serde_json::from_str(&raw).ok()
198 }
199
200 #[cfg(any(test, feature = "test-support"))]
201 static TEST_CATALOG_LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
202 std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
203
204 #[cfg(any(test, feature = "test-support"))]
205 pub fn test_catalog_lock() -> std::sync::MutexGuard<'static, ()> {
206 TEST_CATALOG_LOCK.lock().expect("model catalog test lock")
207 }
208
209 #[cfg(any(test, feature = "test-support"))]
210 pub struct ActiveCatalogGuard {
211 previous: MergedCatalog,
212 }
213
214 #[cfg(any(test, feature = "test-support"))]
215 impl Drop for ActiveCatalogGuard {
216 fn drop(&mut self) {
217 let mut active = active_catalog().write().expect("active catalog write lock");
218 *active = self.previous.clone();
219 }
220 }
221
222 #[cfg(any(test, feature = "test-support"))]
223 pub fn replace_active_catalog_for_test(catalog: MergedCatalog) -> ActiveCatalogGuard {
224 let mut active = active_catalog().write().expect("active catalog write lock");
225 let previous = active.clone();
226 *active = catalog;
227 ActiveCatalogGuard { previous }
228 }
229
230 #[cfg(test)]
231 mod tests {
232 use super::*;
233
234 fn entry(id: &str, context_window: u32, provenance: MetadataProvenance) -> CatalogEntry {
235 CatalogEntry {
236 id: id.to_string(),
237 context_window: Some(context_window),
238 max_output: Some(context_window / 2),
239 supports_reasoning: Some(false),
240 input_usd_per_million: None,
241 output_usd_per_million: None,
242 modalities: Vec::new(),
243 supported_parameters: Vec::new(),
244 provider_model_id: None,
245 provenance,
246 }
247 }
248
249 fn cache(
250 fetched_at: DateTime<Utc>,
251 ttl_secs: u64,
252 entries: BTreeMap<String, CatalogEntry>,
253 ) -> CatalogCache {
254 CatalogCache {
255 schema_version: 1,
256 source: "test".to_string(),
257 fetched_at,
258 ttl_secs,
259 entries,
260 }
261 }
262
263 #[test]
264 fn bundled_snapshot_ttl_is_bounded_to_a_month() {
265 // A ten-year TTL made staleness unfirable and shipped a frozen list
266 // as current (audit A2). Thirty days is the ceiling.
267 let bundled = bundled_catalog();
268 assert!(
269 bundled.ttl_secs <= 2_678_400,
270 "bundled ttl_secs {} exceeds 31 days",
271 bundled.ttl_secs
272 );
273 }
274
275 #[test]
276 fn stale_bundled_snapshot_still_resolves() {
277 let bundled = bundled_catalog();
278 let some_model = bundled.entries.keys().next().cloned().expect("entries");
279 let past = bundled.fetched_at + Duration::seconds(bundled.ttl_secs as i64 + 60);
280 let catalog = MergedCatalog::from_sources(BTreeMap::new(), None, bundled, past);
281 assert!(catalog.bundled_stale());
282 assert!(
283 catalog.resolve(&some_model).is_some(),
284 "offline fallback must keep resolving"
285 );
286 }
287
288 #[test]
289 fn bundled_snapshot_parses_and_is_nonempty() {
290 let bundled = bundled_catalog();
291 assert_eq!(bundled.schema_version, 1);
292 assert!(!bundled.entries.is_empty());
293 assert_eq!(
294 bundled.entries["deepseek-v4-pro"].provenance,
295 MetadataProvenance::Bundled
296 );
297 }
298
299 #[test]
300 fn merge_order_is_user_override_then_provider_then_bundled() {
301 let now = Utc::now();
302 let mut bundled_entries = BTreeMap::new();
303 bundled_entries.insert(
304 "sample/model".to_string(),
305 entry("sample/model", 1_000, MetadataProvenance::Bundled),
306 );
307 let bundled = cache(now, 3600, bundled_entries);
308
309 let mut provider_entries = BTreeMap::new();
310 provider_entries.insert(
311 "sample/model".to_string(),
312 entry("sample/model", 2_000, MetadataProvenance::ProviderApi),
313 );
314 let provider_cache = cache(now, 3600, provider_entries);
315
316 let mut override_entries = BTreeMap::new();
317 override_entries.insert(
318 "sample/model".to_string(),
319 entry("sample/model", 3_000, MetadataProvenance::UserOverride),
320 );
321
322 let merged =
323 MergedCatalog::from_sources(override_entries, Some(provider_cache), bundled, now);
324 let resolved = merged.resolve("sample/model").expect("resolved");
325 assert_eq!(resolved.context_window, Some(3_000));
326 assert_eq!(resolved.provenance, MetadataProvenance::UserOverride);
327 }
328
329 #[test]
330 fn stale_cache_is_ignored_for_facts() {
331 let now = Utc::now();
332 let mut bundled_entries = BTreeMap::new();
333 bundled_entries.insert(
334 "sample/model".to_string(),
335 entry("sample/model", 1_000, MetadataProvenance::Bundled),
336 );
337 let bundled = cache(now, 3600, bundled_entries);
338
339 let mut provider_entries = BTreeMap::new();
340 provider_entries.insert(
341 "sample/model".to_string(),
342 entry("sample/model", 9_000, MetadataProvenance::ProviderApi),
343 );
344 let provider_cache = cache(now - Duration::seconds(10), 1, provider_entries);
345 assert!(provider_cache.is_stale(now));
346
347 let merged =
348 MergedCatalog::from_sources(BTreeMap::new(), Some(provider_cache), bundled, now);
349 let resolved = merged.resolve("sample/model").expect("resolved");
350 assert_eq!(resolved.context_window, Some(1_000));
351 assert_eq!(resolved.provenance, MetadataProvenance::Bundled);
352 }
353
354 #[test]
355 fn cache_roundtrip_serializes_no_secret_fields() {
356 let mut entries = BTreeMap::new();
357 entries.insert(
358 "sample/model".to_string(),
359 CatalogEntry {
360 input_usd_per_million: Some(0.25),
361 output_usd_per_million: Some(1.25),
362 ..entry("sample/model", 32_000, MetadataProvenance::ProviderApi)
363 },
364 );
365 let cache = cache(Utc::now(), 60, entries);
366 let json = serde_json::to_string_pretty(&cache).expect("serialize");
367 let lowered = json.to_lowercase();
368 for forbidden in ["api_key", "authorization", "token", "secret"] {
369 assert!(
370 !lowered.contains(forbidden),
371 "cache JSON must not contain auth field {forbidden}: {json}"
372 );
373 }
374 let parsed: CatalogCache = serde_json::from_str(&json).expect("roundtrip");
375 assert_eq!(parsed.entries.len(), 1);
376 }
377 }
378
378 lines RUST