codex-helper-core 0.17.0

Core library for codex-helper.
Documentation
use std::path::Path;

use anyhow::{Context, Result};
use serde_json::Value;
use tokio::fs;
use tracing::info;

const FAST_SERVICE_TIER_SLUGS: &[&str] = &["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelsCacheInvalidation {
    Missing,
    Kept,
    Deleted,
}

pub async fn invalidate_stale_fast_service_tier_cache(
    cache_path: &Path,
) -> Result<ModelsCacheInvalidation> {
    let contents = match fs::read(cache_path).await {
        Ok(contents) => contents,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return Ok(ModelsCacheInvalidation::Missing);
        }
        Err(err) => return Err(err).with_context(|| format!("read {:?}", cache_path)),
    };

    let value = serde_json::from_slice::<Value>(&contents)
        .with_context(|| format!("parse {:?}", cache_path))?;
    let Some(stale_slug) = stale_fast_service_tier_slug(&value) else {
        return Ok(ModelsCacheInvalidation::Kept);
    };

    fs::remove_file(cache_path)
        .await
        .with_context(|| format!("remove stale Codex models cache {:?}", cache_path))?;
    info!(
        cache_path = %cache_path.display(),
        stale_slug,
        "removed Codex models cache because a known Fast model was cached without priority service_tiers"
    );
    Ok(ModelsCacheInvalidation::Deleted)
}

fn stale_fast_service_tier_slug(value: &Value) -> Option<&'static str> {
    let models = value.get("models")?.as_array()?;
    FAST_SERVICE_TIER_SLUGS
        .iter()
        .copied()
        .find(|slug| model_exists_without_fast_service_tier(models, slug))
}

fn model_exists_without_fast_service_tier(models: &[Value], slug: &str) -> bool {
    models
        .iter()
        .find(|model| model.get("slug").and_then(Value::as_str) == Some(slug))
        .is_some_and(|model| !model_has_fast_service_tier(model))
}

fn model_has_fast_service_tier(model: &Value) -> bool {
    model
        .get("service_tiers")
        .and_then(Value::as_array)
        .is_some_and(|service_tiers| {
            service_tiers.iter().any(|tier| {
                tier.get("id").and_then(Value::as_str) == Some("priority")
                    || tier.get("name").and_then(Value::as_str) == Some("Fast")
            })
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn temp_cache_path() -> std::path::PathBuf {
        std::env::temp_dir()
            .join(format!(
                "codex-helper-models-cache-{}",
                uuid::Uuid::new_v4()
            ))
            .join("models_cache.json")
    }

    async fn write_cache(path: &Path, json: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).await.expect("create temp dir");
        }
        fs::write(path, json).await.expect("write cache");
    }

    #[tokio::test]
    async fn invalidates_known_fast_model_without_service_tier() {
        let path = temp_cache_path();
        write_cache(
            &path,
            r#"{
                "fetched_at": "2026-05-22T03:11:27Z",
                "client_version": "0.133.0",
                "models": [
                    { "slug": "gpt-5.5", "service_tiers": [] }
                ]
            }"#,
        )
        .await;

        let result = invalidate_stale_fast_service_tier_cache(&path)
            .await
            .expect("invalidate cache");

        assert_eq!(result, ModelsCacheInvalidation::Deleted);
        assert!(!path.exists());
    }

    #[tokio::test]
    async fn keeps_known_fast_model_with_priority_service_tier() {
        let path = temp_cache_path();
        write_cache(
            &path,
            r#"{
                "models": [
                    {
                        "slug": "gpt-5.5",
                        "service_tiers": [
                            { "id": "priority", "name": "Fast" }
                        ]
                    }
                ]
            }"#,
        )
        .await;

        let result = invalidate_stale_fast_service_tier_cache(&path)
            .await
            .expect("check cache");

        assert_eq!(result, ModelsCacheInvalidation::Kept);
        assert!(path.exists());
    }

    #[tokio::test]
    async fn keeps_cache_without_known_fast_models() {
        let path = temp_cache_path();
        write_cache(
            &path,
            r#"{
                "models": [
                    { "slug": "gpt-5.2", "service_tiers": [] }
                ]
            }"#,
        )
        .await;

        let result = invalidate_stale_fast_service_tier_cache(&path)
            .await
            .expect("check cache");

        assert_eq!(result, ModelsCacheInvalidation::Kept);
        assert!(path.exists());
    }

    #[tokio::test]
    async fn reports_missing_cache() {
        let path = temp_cache_path();

        let result = invalidate_stale_fast_service_tier_cache(&path)
            .await
            .expect("check missing cache");

        assert_eq!(result, ModelsCacheInvalidation::Missing);
    }
}