codexia 0.1.0

OpenAI- and Anthropic-compatible local API gateway backed by Codex OAuth.
Documentation
use crate::{Error, Result, openai::response::ModelList};
use serde::Deserialize;
use std::{collections::HashSet, fs, path::Path};

pub const OPENCLAW_CODEX_MODELS: &[&str] = &[
    "gpt-5.1",
    "gpt-5.1-codex-max",
    "gpt-5.1-codex-mini",
    "gpt-5.2",
    "gpt-5.2-codex",
    "gpt-5.3-codex",
    "gpt-5.3-codex-spark",
    "gpt-5.4",
    "gpt-5.4-mini",
    "gpt-5.5",
    "gpt-5.5-mini",
];

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelOptions {
    pub replacement_models: Vec<String>,
    pub extra_models: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct ModelFileObject {
    #[serde(default)]
    models: Vec<String>,
    #[serde(default)]
    extra_models: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ModelFile {
    List(Vec<String>),
    Object(ModelFileObject),
}

impl ModelOptions {
    pub fn from_file(path: &Path) -> Result<Self> {
        let raw = fs::read_to_string(path)?;
        let file = serde_json::from_str::<ModelFile>(&raw)?;
        Ok(match file {
            ModelFile::List(models) => Self {
                replacement_models: models,
                extra_models: Vec::new(),
            },
            ModelFile::Object(object) => Self {
                replacement_models: object.models,
                extra_models: object.extra_models,
            },
        })
    }
}

pub fn resolve_model_ids(
    file_options: Option<ModelOptions>,
    cli_options: ModelOptions,
) -> Vec<String> {
    let mut ids = file_options
        .as_ref()
        .filter(|options| has_non_empty_model(&options.replacement_models))
        .map(|options| options.replacement_models.clone())
        .unwrap_or_else(|| {
            OPENCLAW_CODEX_MODELS
                .iter()
                .map(ToString::to_string)
                .collect()
        });

    if has_non_empty_model(&cli_options.replacement_models) {
        ids = cli_options.replacement_models.clone();
    }

    if let Some(options) = file_options {
        ids.extend(options.extra_models);
    }
    ids.extend(cli_options.extra_models);

    normalize_model_ids(ids)
}

pub fn resolve_model_list(
    models_file: Option<&Path>,
    cli_options: ModelOptions,
) -> Result<ModelList> {
    let file_options = models_file
        .map(ModelOptions::from_file)
        .transpose()
        .map_err(|error| Error::config(format!("failed to load models file: {error}")))?;
    Ok(ModelList::from_ids(resolve_model_ids(
        file_options,
        cli_options,
    )))
}

fn has_non_empty_model(models: &[String]) -> bool {
    models.iter().any(|model| !model.trim().is_empty())
}

fn normalize_model_ids(ids: impl IntoIterator<Item = String>) -> Vec<String> {
    let mut seen = HashSet::new();
    ids.into_iter()
        .map(|id| id.trim().to_owned())
        .filter(|id| !id.is_empty())
        .filter(|id| seen.insert(id.clone()))
        .collect()
}

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

    #[test]
    fn defaults_to_openclaw_codex_models() {
        let ids = resolve_model_ids(None, ModelOptions::default());
        let expected = OPENCLAW_CODEX_MODELS
            .iter()
            .map(ToString::to_string)
            .collect::<Vec<_>>();

        assert_eq!(ids, expected);
    }

    #[test]
    fn defaults_include_gpt_55_models() {
        let ids = resolve_model_ids(None, ModelOptions::default());

        assert!(ids.iter().any(|id| id == "gpt-5.5"));
        assert!(ids.iter().any(|id| id == "gpt-5.5-mini"));
    }

    #[test]
    fn cli_replacement_overrides_defaults_and_dedupes() {
        let ids = resolve_model_ids(
            None,
            ModelOptions {
                replacement_models: vec!["custom".into(), " custom ".into(), "".into()],
                extra_models: vec![],
            },
        );

        assert_eq!(ids, vec!["custom"]);
    }

    #[test]
    fn file_replacement_and_cli_extra_are_combined() {
        let ids = resolve_model_ids(
            Some(ModelOptions {
                replacement_models: vec!["file-model".into()],
                extra_models: vec!["file-extra".into()],
            }),
            ModelOptions {
                replacement_models: vec![],
                extra_models: vec!["cli-extra".into()],
            },
        );

        assert_eq!(ids, vec!["file-model", "file-extra", "cli-extra"]);
    }

    #[test]
    fn parses_models_file_object() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("models.json");
        fs::write(
            &path,
            r#"{"models":["a"],"extra_models":["b","a","  c  "]}"#,
        )
        .unwrap();

        let list = resolve_model_list(Some(&path), ModelOptions::default()).unwrap();
        let ids = list
            .data
            .into_iter()
            .map(|model| model.id)
            .collect::<Vec<_>>();

        assert_eq!(ids, vec!["a", "b", "c"]);
    }
}