codex_helper_core/
codex_models_cache.rs1use std::path::Path;
2
3use anyhow::{Context, Result};
4use serde_json::Value;
5use tokio::fs;
6use tracing::info;
7
8const FAST_SERVICE_TIER_SLUGS: &[&str] = &["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"];
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ModelsCacheInvalidation {
12 Missing,
13 Kept,
14 Deleted,
15}
16
17pub async fn invalidate_stale_fast_service_tier_cache(
18 cache_path: &Path,
19) -> Result<ModelsCacheInvalidation> {
20 let contents = match fs::read(cache_path).await {
21 Ok(contents) => contents,
22 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
23 return Ok(ModelsCacheInvalidation::Missing);
24 }
25 Err(err) => return Err(err).with_context(|| format!("read {:?}", cache_path)),
26 };
27
28 let value = serde_json::from_slice::<Value>(&contents)
29 .with_context(|| format!("parse {:?}", cache_path))?;
30 let Some(stale_slug) = stale_fast_service_tier_slug(&value) else {
31 return Ok(ModelsCacheInvalidation::Kept);
32 };
33
34 fs::remove_file(cache_path)
35 .await
36 .with_context(|| format!("remove stale Codex models cache {:?}", cache_path))?;
37 info!(
38 cache_path = %cache_path.display(),
39 stale_slug,
40 "removed Codex models cache because a known Fast model was cached without priority service_tiers"
41 );
42 Ok(ModelsCacheInvalidation::Deleted)
43}
44
45fn stale_fast_service_tier_slug(value: &Value) -> Option<&'static str> {
46 let models = value.get("models")?.as_array()?;
47 FAST_SERVICE_TIER_SLUGS
48 .iter()
49 .copied()
50 .find(|slug| model_exists_without_fast_service_tier(models, slug))
51}
52
53fn model_exists_without_fast_service_tier(models: &[Value], slug: &str) -> bool {
54 models
55 .iter()
56 .find(|model| model.get("slug").and_then(Value::as_str) == Some(slug))
57 .is_some_and(|model| !model_has_fast_service_tier(model))
58}
59
60fn model_has_fast_service_tier(model: &Value) -> bool {
61 model
62 .get("service_tiers")
63 .and_then(Value::as_array)
64 .is_some_and(|service_tiers| {
65 service_tiers.iter().any(|tier| {
66 tier.get("id").and_then(Value::as_str) == Some("priority")
67 || tier.get("name").and_then(Value::as_str) == Some("Fast")
68 })
69 })
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 fn temp_cache_path() -> std::path::PathBuf {
77 std::env::temp_dir()
78 .join(format!(
79 "codex-helper-models-cache-{}",
80 uuid::Uuid::new_v4()
81 ))
82 .join("models_cache.json")
83 }
84
85 async fn write_cache(path: &Path, json: &str) {
86 if let Some(parent) = path.parent() {
87 fs::create_dir_all(parent).await.expect("create temp dir");
88 }
89 fs::write(path, json).await.expect("write cache");
90 }
91
92 #[tokio::test]
93 async fn invalidates_known_fast_model_without_service_tier() {
94 let path = temp_cache_path();
95 write_cache(
96 &path,
97 r#"{
98 "fetched_at": "2026-05-22T03:11:27Z",
99 "client_version": "0.133.0",
100 "models": [
101 { "slug": "gpt-5.5", "service_tiers": [] }
102 ]
103 }"#,
104 )
105 .await;
106
107 let result = invalidate_stale_fast_service_tier_cache(&path)
108 .await
109 .expect("invalidate cache");
110
111 assert_eq!(result, ModelsCacheInvalidation::Deleted);
112 assert!(!path.exists());
113 }
114
115 #[tokio::test]
116 async fn keeps_known_fast_model_with_priority_service_tier() {
117 let path = temp_cache_path();
118 write_cache(
119 &path,
120 r#"{
121 "models": [
122 {
123 "slug": "gpt-5.5",
124 "service_tiers": [
125 { "id": "priority", "name": "Fast" }
126 ]
127 }
128 ]
129 }"#,
130 )
131 .await;
132
133 let result = invalidate_stale_fast_service_tier_cache(&path)
134 .await
135 .expect("check cache");
136
137 assert_eq!(result, ModelsCacheInvalidation::Kept);
138 assert!(path.exists());
139 }
140
141 #[tokio::test]
142 async fn keeps_cache_without_known_fast_models() {
143 let path = temp_cache_path();
144 write_cache(
145 &path,
146 r#"{
147 "models": [
148 { "slug": "gpt-5.2", "service_tiers": [] }
149 ]
150 }"#,
151 )
152 .await;
153
154 let result = invalidate_stale_fast_service_tier_cache(&path)
155 .await
156 .expect("check cache");
157
158 assert_eq!(result, ModelsCacheInvalidation::Kept);
159 assert!(path.exists());
160 }
161
162 #[tokio::test]
163 async fn reports_missing_cache() {
164 let path = temp_cache_path();
165
166 let result = invalidate_stale_fast_service_tier_cache(&path)
167 .await
168 .expect("check missing cache");
169
170 assert_eq!(result, ModelsCacheInvalidation::Missing);
171 }
172}