返回 CodeWhale
codex_model_cache.rs
根目录 / crates / tui / src / codex_model_cache.rs
1 //! Secret-free OpenAI Codex / ChatGPT OAuth model roster discovery.
2 //!
3 //! The Codex CLI keeps its account-scoped roster in `models_cache.json`.
4 //! CodeWhale reads only the cache timestamp and model identifiers; it never
5 //! opens the adjacent OAuth credential file and never logs cache contents.
6
7 use std::collections::HashSet;
8 use std::io::Read;
9 use std::path::{Path, PathBuf};
10
11 #[cfg(unix)]
12 use std::os::unix::fs::OpenOptionsExt;
13
14 use chrono::{DateTime, Duration, Utc};
15 use serde::Deserialize;
16
17 use crate::config::DEFAULT_OPENAI_CODEX_MODEL;
18
19 const MODEL_CACHE_FILE: &str = "models_cache.json";
20 const MAX_MODEL_CACHE_BYTES: u64 = 4 * 1024 * 1024;
21 /// Codex refreshes its own cache much more frequently. CodeWhale is an offline
22 /// consumer, so it accepts a last-known account roster for one day before
23 /// falling back to the single conservative compatibility model.
24 const MODEL_CACHE_MAX_AGE: Duration = Duration::hours(24);
25 const MAX_FUTURE_CLOCK_SKEW: Duration = Duration::minutes(5);
26
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub(crate) enum CodexModelCacheFreshness {
29 Fresh,
30 Missing,
31 Stale,
32 Invalid,
33 }
34
35 impl CodexModelCacheFreshness {
36 #[must_use]
37 pub(crate) const fn picker_label(self) -> &'static str {
38 match self {
39 Self::Fresh => "ChatGPT OAuth",
40 Self::Missing => "OAuth roster missing · fallback",
41 Self::Stale => "OAuth roster stale · fallback",
42 Self::Invalid => "OAuth roster invalid · fallback",
43 }
44 }
45 }
46
47 #[derive(Debug, Clone, PartialEq, Eq)]
48 pub(crate) struct CodexModelRoster {
49 pub(crate) models: Vec<CodexModelMetadata>,
50 pub(crate) freshness: CodexModelCacheFreshness,
51 pub(crate) fetched_at: Option<DateTime<Utc>>,
52 }
53
54 #[derive(Debug, Clone, PartialEq, Eq)]
55 pub(crate) struct CodexModelMetadata {
56 pub(crate) id: String,
57 pub(crate) context_window: Option<u32>,
58 pub(crate) reasoning: Option<bool>,
59 }
60
61 impl CodexModelRoster {
62 fn fallback(freshness: CodexModelCacheFreshness, fetched_at: Option<DateTime<Utc>>) -> Self {
63 Self {
64 models: vec![CodexModelMetadata {
65 id: DEFAULT_OPENAI_CODEX_MODEL.to_string(),
66 context_window: None,
67 reasoning: None,
68 }],
69 freshness,
70 fetched_at,
71 }
72 }
73
74 #[must_use]
75 pub(crate) fn model_ids(&self) -> Vec<String> {
76 self.models.iter().map(|model| model.id.clone()).collect()
77 }
78
79 #[must_use]
80 pub(crate) fn metadata_for(&self, id: &str) -> Option<&CodexModelMetadata> {
81 self.models
82 .iter()
83 .find(|model| model.id.eq_ignore_ascii_case(id.trim()))
84 }
85
86 /// The roster's preferred model: the highest-priority entry of a fresh
87 /// roster. Missing/stale/invalid rosters yield `None` so callers keep
88 /// the static seed default (#5034).
89 #[must_use]
90 pub(crate) fn preferred_model_id(&self) -> Option<&str> {
91 if self.freshness != CodexModelCacheFreshness::Fresh {
92 return None;
93 }
94 self.models.first().map(|model| model.id.as_str())
95 }
96 }
97
98 #[derive(Debug, Deserialize)]
99 struct CacheFile {
100 fetched_at: DateTime<Utc>,
101 #[serde(default)]
102 models: Vec<CacheModel>,
103 }
104
105 #[derive(Debug, Deserialize)]
106 struct CacheModel {
107 slug: String,
108 #[serde(default)]
109 priority: Option<i64>,
110 #[serde(default)]
111 context_window: Option<u32>,
112 #[serde(default)]
113 supported_reasoning_levels: Option<Vec<CacheReasoningLevel>>,
114 }
115
116 #[derive(Debug, Deserialize)]
117 struct CacheReasoningLevel {}
118
119 /// Resolve the Codex home without consulting OAuth-file overrides.
120 ///
121 /// `OPENAI_CODEX_AUTH_FILE` intentionally does not participate: it may point
122 /// at a standalone test/credential file while the model roster still belongs
123 /// to `$CODEX_HOME` (or the default `~/.codex`).
124 #[must_use]
125 pub(crate) fn codex_home_path() -> PathBuf {
126 std::env::var_os("CODEX_HOME")
127 .filter(|value| !value.is_empty())
128 .map(PathBuf::from)
129 .unwrap_or_else(|| {
130 crate::config::effective_home_dir()
131 .unwrap_or_else(|| PathBuf::from("."))
132 .join(".codex")
133 })
134 }
135
136 #[must_use]
137 pub(crate) fn model_roster() -> CodexModelRoster {
138 load_model_roster_from_home_at(&codex_home_path(), Utc::now())
139 }
140
141 fn load_model_roster_from_home_at(home: &Path, now: DateTime<Utc>) -> CodexModelRoster {
142 let path = home.join(MODEL_CACHE_FILE);
143 let path_metadata = match std::fs::symlink_metadata(&path) {
144 Ok(metadata) => metadata,
145 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
146 return CodexModelRoster::fallback(CodexModelCacheFreshness::Missing, None);
147 }
148 Err(_) => return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None),
149 };
150 if !path_metadata.file_type().is_file() || path_metadata.len() > MAX_MODEL_CACHE_BYTES {
151 return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None);
152 }
153 let mut file = match open_cache_file(&path) {
154 Ok(file) => file,
155 Err(_) => return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None),
156 };
157 let metadata = match file.metadata() {
158 Ok(metadata) => metadata,
159 Err(_) => return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None),
160 };
161 if !metadata.file_type().is_file() || metadata.len() > MAX_MODEL_CACHE_BYTES {
162 return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None);
163 }
164
165 let mut bytes = Vec::with_capacity(metadata.len().min(MAX_MODEL_CACHE_BYTES) as usize);
166 if file
167 .by_ref()
168 .take(MAX_MODEL_CACHE_BYTES + 1)
169 .read_to_end(&mut bytes)
170 .is_err()
171 || bytes.len() as u64 > MAX_MODEL_CACHE_BYTES
172 {
173 return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None);
174 }
175 let cache: CacheFile = match serde_json::from_slice(&bytes) {
176 Ok(cache) => cache,
177 Err(_) => return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None),
178 };
179
180 let age = now.signed_duration_since(cache.fetched_at);
181 if age < -MAX_FUTURE_CLOCK_SKEW {
182 return CodexModelRoster::fallback(
183 CodexModelCacheFreshness::Invalid,
184 Some(cache.fetched_at),
185 );
186 }
187 if age > MODEL_CACHE_MAX_AGE {
188 return CodexModelRoster::fallback(CodexModelCacheFreshness::Stale, Some(cache.fetched_at));
189 }
190
191 let mut indexed: Vec<_> = cache.models.into_iter().enumerate().collect();
192 indexed.sort_by_key(|(index, model)| (model.priority.unwrap_or(i64::MAX), *index));
193
194 let mut seen = HashSet::new();
195 let mut models = Vec::new();
196 for (_, model) in indexed {
197 let slug = model.slug.trim();
198 if !valid_model_id(slug) {
199 continue;
200 }
201 let identity = slug.to_ascii_lowercase();
202 if seen.insert(identity) {
203 models.push(CodexModelMetadata {
204 id: slug.to_string(),
205 context_window: model
206 .context_window
207 .filter(|window| (1..=16_000_000).contains(window)),
208 reasoning: model
209 .supported_reasoning_levels
210 .map(|levels| !levels.is_empty()),
211 });
212 }
213 }
214 if models.is_empty() {
215 return CodexModelRoster::fallback(
216 CodexModelCacheFreshness::Invalid,
217 Some(cache.fetched_at),
218 );
219 }
220
221 CodexModelRoster {
222 models,
223 freshness: CodexModelCacheFreshness::Fresh,
224 fetched_at: Some(cache.fetched_at),
225 }
226 }
227
228 fn open_cache_file(path: &Path) -> std::io::Result<std::fs::File> {
229 let mut options = std::fs::OpenOptions::new();
230 options.read(true);
231 #[cfg(unix)]
232 options.custom_flags(libc::O_NOFOLLOW);
233 options.open(path)
234 }
235
236 fn valid_model_id(value: &str) -> bool {
237 !value.is_empty()
238 && value.len() <= 256
239 && value.bytes().any(|byte| byte.is_ascii_alphanumeric())
240 && value.bytes().all(|byte| {
241 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-')
242 })
243 }
244
245 #[cfg(test)]
246 mod tests {
247 use super::*;
248
249 const FIXTURE: &str = include_str!("../tests/fixtures/codex_models_cache.json");
250 const FIXTURE_TIME: &str = "2030-01-02T03:04:05Z";
251
252 fn fixture_time() -> DateTime<Utc> {
253 FIXTURE_TIME.parse().expect("fixture timestamp")
254 }
255
256 fn write_fixture(home: &Path) {
257 std::fs::write(home.join(MODEL_CACHE_FILE), FIXTURE).expect("write fixture");
258 }
259
260 #[test]
261 fn valid_cache_uses_priority_order_and_keeps_route_available_rows() {
262 let home = tempfile::tempdir().expect("temp CODEX_HOME");
263 write_fixture(home.path());
264
265 let roster =
266 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::minutes(30));
267
268 assert_eq!(roster.freshness, CodexModelCacheFreshness::Fresh);
269 assert_eq!(roster.fetched_at, Some(fixture_time()));
270 assert_eq!(
271 roster.model_ids(),
272 [
273 "gpt-test-primary",
274 "gpt-test-secondary",
275 "codex-test-review"
276 ]
277 );
278 let primary = roster
279 .metadata_for("gpt-test-primary")
280 .expect("primary metadata");
281 assert_eq!(primary.context_window, Some(372_000));
282 assert_eq!(primary.reasoning, Some(true));
283 let secondary = roster
284 .metadata_for("gpt-test-secondary")
285 .expect("secondary metadata");
286 assert_eq!(secondary.context_window, Some(128_000));
287 }
288
289 #[test]
290 fn missing_cache_falls_back_conservatively() {
291 let home = tempfile::tempdir().expect("temp CODEX_HOME");
292 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
293
294 assert_eq!(roster.freshness, CodexModelCacheFreshness::Missing);
295 assert_eq!(roster.model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
296 }
297
298 #[test]
299 fn preferred_model_is_the_fresh_roster_head_only() {
300 let home = tempfile::tempdir().expect("temp CODEX_HOME");
301 write_fixture(home.path());
302
303 let fresh =
304 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::minutes(30));
305 assert_eq!(fresh.preferred_model_id(), Some("gpt-test-primary"));
306
307 // Stale and missing rosters must keep the static seed default so a
308 // provider switch never trusts outdated route knowledge (#5034).
309 let stale =
310 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::days(365));
311 assert_eq!(stale.preferred_model_id(), None);
312 let missing = load_model_roster_from_home_at(
313 tempfile::tempdir().expect("empty home").path(),
314 fixture_time(),
315 );
316 assert_eq!(missing.preferred_model_id(), None);
317 }
318
319 #[test]
320 fn malformed_cache_falls_back_conservatively() {
321 let home = tempfile::tempdir().expect("temp CODEX_HOME");
322 std::fs::write(home.path().join(MODEL_CACHE_FILE), b"{not-json")
323 .expect("write malformed cache");
324
325 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
326
327 assert_eq!(roster.freshness, CodexModelCacheFreshness::Invalid);
328 assert_eq!(roster.model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
329 }
330
331 #[test]
332 fn oversized_cache_is_rejected_without_unbounded_read() {
333 let home = tempfile::tempdir().expect("temp CODEX_HOME");
334 let file = std::fs::File::create(home.path().join(MODEL_CACHE_FILE)).expect("cache file");
335 file.set_len(MAX_MODEL_CACHE_BYTES + 1)
336 .expect("sparse oversized cache");
337
338 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
339
340 assert_eq!(roster.freshness, CodexModelCacheFreshness::Invalid);
341 }
342
343 #[cfg(unix)]
344 #[test]
345 fn symlink_cache_is_rejected_as_non_regular_input() {
346 let home = tempfile::tempdir().expect("temp CODEX_HOME");
347 let target = home.path().join("target.json");
348 std::fs::write(&target, FIXTURE).expect("target fixture");
349 std::os::unix::fs::symlink(&target, home.path().join(MODEL_CACHE_FILE))
350 .expect("cache symlink");
351
352 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
353
354 assert_eq!(roster.freshness, CodexModelCacheFreshness::Invalid);
355 }
356
357 #[test]
358 fn stale_cache_falls_back_conservatively() {
359 let home = tempfile::tempdir().expect("temp CODEX_HOME");
360 write_fixture(home.path());
361
362 let roster =
363 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::hours(25));
364
365 assert_eq!(roster.freshness, CodexModelCacheFreshness::Stale);
366 assert_eq!(roster.model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
367 assert_eq!(roster.fetched_at, Some(fixture_time()));
368 }
369
370 #[test]
371 fn invalid_and_duplicate_model_ids_are_filtered() {
372 let home = tempfile::tempdir().expect("temp CODEX_HOME");
373 let cache = format!(
374 r#"{{
375 "fetched_at": "{FIXTURE_TIME}",
376 "models": [
377 {{"slug": "gpt-good", "priority": 3}},
378 {{"slug": "GPT-GOOD", "priority": 4}},
379 {{"slug": "bad model", "priority": 1}},
380 {{"slug": "../bad\\path", "priority": 2}}
381 ]
382 }}"#
383 );
384 std::fs::write(home.path().join(MODEL_CACHE_FILE), cache).expect("write cache");
385
386 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
387
388 assert_eq!(roster.freshness, CodexModelCacheFreshness::Fresh);
389 assert_eq!(roster.model_ids(), ["gpt-good"]);
390 }
391
392 #[test]
393 fn codex_home_respects_environment_override() {
394 let lock = crate::test_support::lock_test_env();
395 let home = tempfile::tempdir().expect("temp CODEX_HOME");
396 let guard = crate::test_support::EnvVarGuard::set("CODEX_HOME", home.path());
397
398 assert_eq!(codex_home_path(), home.path());
399
400 drop(guard);
401 drop(lock);
402 }
403 }
404
404 lines RUST