| 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 |