Skip to main content

codex_helper_core/
codex_models_cache.rs

1use 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}