prodex 0.47.0

OpenAI profile pooling and safe auto-rotate for Codex CLI and Claude Code
Documentation
use super::config::runtime_proxy_claude_config_value;
use super::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RuntimeProxyClaudeModelAlias {
    Opus,
    Sonnet,
    Haiku,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct RuntimeProxyResponsesModelDescriptor {
    pub(crate) id: &'static str,
    pub(crate) display_name: &'static str,
    pub(crate) description: &'static str,
    pub(crate) claude_alias: Option<RuntimeProxyClaudeModelAlias>,
    pub(crate) legacy_claude_picker_model: Option<&'static str>,
    pub(crate) supports_xhigh: bool,
}

impl RuntimeProxyResponsesModelDescriptor {
    fn claude_picker_value(self) -> &'static str {
        self.claude_alias
            .map(runtime_proxy_claude_alias_picker_value)
            .unwrap_or(self.id)
    }

    fn matches_claude_picker_value(self, picker_model: &str) -> bool {
        self.legacy_claude_picker_model
            .is_some_and(|value| value.eq_ignore_ascii_case(picker_model))
            || self
                .claude_picker_value()
                .eq_ignore_ascii_case(picker_model)
    }
}

pub(crate) fn runtime_proxy_claude_model_override() -> Option<String> {
    env::var("PRODEX_CLAUDE_MODEL")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

pub(crate) fn runtime_proxy_normalize_responses_reasoning_effort(
    effort: &str,
) -> Option<&'static str> {
    match effort.trim().to_ascii_lowercase().as_str() {
        "minimal" => Some("minimal"),
        "low" => Some("low"),
        "medium" => Some("medium"),
        "high" => Some("high"),
        "xhigh" => Some("xhigh"),
        "none" => Some("none"),
        // Claude Code exposes `max`; treat it as the strongest explicit upstream tier.
        "max" => Some("xhigh"),
        _ => None,
    }
}

pub(crate) fn runtime_proxy_claude_reasoning_effort_override() -> Option<String> {
    env::var("PRODEX_CLAUDE_REASONING_EFFORT")
        .ok()
        .and_then(|value| {
            runtime_proxy_normalize_responses_reasoning_effort(value.trim()).map(str::to_string)
        })
}

pub(crate) fn runtime_proxy_claude_native_client_tool_enabled(enabled_tokens: &[&str]) -> bool {
    let Some(value) = env::var("PRODEX_CLAUDE_NATIVE_CLIENT_TOOLS").ok() else {
        return false;
    };
    let mut enabled = false;
    for token in value
        .split(',')
        .map(str::trim)
        .filter(|token| !token.is_empty())
    {
        match token.to_ascii_lowercase().as_str() {
            "0" | "false" | "no" | "off" | "none" => return false,
            "1" | "true" | "yes" | "on" | "all" => enabled = true,
            value if enabled_tokens.contains(&value) => enabled = true,
            _ => {}
        }
    }
    enabled
}

pub(crate) fn runtime_proxy_claude_native_shell_enabled() -> bool {
    runtime_proxy_claude_native_client_tool_enabled(&["shell", "bash"])
}

pub(crate) fn runtime_proxy_claude_native_computer_enabled() -> bool {
    runtime_proxy_claude_native_client_tool_enabled(&["computer"])
}

pub(crate) fn runtime_proxy_claude_launch_model(codex_home: &Path) -> String {
    runtime_proxy_claude_model_override()
        .or_else(|| runtime_proxy_claude_config_value(codex_home, "model"))
        .unwrap_or_else(|| DEFAULT_PRODEX_CLAUDE_MODEL.to_string())
}

pub(crate) fn runtime_proxy_claude_alias_env_keys(
    alias: RuntimeProxyClaudeModelAlias,
) -> (&'static str, &'static str, &'static str, &'static str) {
    match alias {
        RuntimeProxyClaudeModelAlias::Opus => (
            "ANTHROPIC_DEFAULT_OPUS_MODEL",
            "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
            "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION",
            "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES",
        ),
        RuntimeProxyClaudeModelAlias::Sonnet => (
            "ANTHROPIC_DEFAULT_SONNET_MODEL",
            "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
            "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION",
            "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES",
        ),
        RuntimeProxyClaudeModelAlias::Haiku => (
            "ANTHROPIC_DEFAULT_HAIKU_MODEL",
            "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
            "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION",
            "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES",
        ),
    }
}

pub(crate) fn runtime_proxy_claude_alias_model(
    alias: RuntimeProxyClaudeModelAlias,
) -> &'static RuntimeProxyResponsesModelDescriptor {
    runtime_proxy_responses_model_descriptors()
        .iter()
        .find(|descriptor| descriptor.claude_alias == Some(alias))
        .expect("Claude alias model should exist")
}

pub(crate) fn runtime_proxy_claude_picker_model_descriptor(
    picker_model: &str,
) -> Option<&'static RuntimeProxyResponsesModelDescriptor> {
    let normalized = picker_model.trim();
    let without_extended_context = normalized.strip_suffix("[1m]").unwrap_or(normalized);
    runtime_proxy_responses_model_descriptor(without_extended_context).or_else(|| {
        runtime_proxy_responses_model_descriptors()
            .iter()
            .find(|descriptor| descriptor.matches_claude_picker_value(without_extended_context))
    })
}

pub(crate) fn runtime_proxy_responses_model_descriptor(
    model_id: &str,
) -> Option<&'static RuntimeProxyResponsesModelDescriptor> {
    runtime_proxy_responses_model_descriptors()
        .iter()
        .find(|descriptor| descriptor.id.eq_ignore_ascii_case(model_id))
}

pub(crate) fn runtime_proxy_responses_model_capabilities(model_id: &str) -> &'static str {
    if runtime_proxy_responses_model_supports_xhigh(model_id) {
        "effort,max_effort,thinking,adaptive_thinking,interleaved_thinking"
    } else {
        "effort,thinking,adaptive_thinking,interleaved_thinking"
    }
}

pub(crate) fn runtime_proxy_responses_model_supported_effort_levels(
    model_id: &str,
) -> &'static [&'static str] {
    if runtime_proxy_responses_model_supports_xhigh(model_id) {
        &["low", "medium", "high", "max"]
    } else {
        &["low", "medium", "high"]
    }
}

pub(crate) fn runtime_proxy_responses_model_supports_xhigh(model_id: &str) -> bool {
    runtime_proxy_responses_model_descriptor(model_id)
        .map(|descriptor| descriptor.supports_xhigh)
        .unwrap_or_else(|| {
            matches!(
                model_id.trim().to_ascii_lowercase().as_str(),
                value
                    if value.starts_with("gpt-5.2")
                        || value.starts_with("gpt-5.3")
                        || value.starts_with("gpt-5.4")
            )
        })
}

pub(crate) fn runtime_proxy_claude_use_foundry_compat() -> bool {
    true
}

pub(crate) fn runtime_proxy_claude_alias_picker_value(
    alias: RuntimeProxyClaudeModelAlias,
) -> &'static str {
    match alias {
        RuntimeProxyClaudeModelAlias::Opus => "opus",
        RuntimeProxyClaudeModelAlias::Sonnet => "sonnet",
        RuntimeProxyClaudeModelAlias::Haiku => "haiku",
    }
}

pub(crate) fn runtime_proxy_claude_pinned_alias_env() -> Vec<(&'static str, OsString)> {
    let mut env = Vec::new();
    for alias in [
        RuntimeProxyClaudeModelAlias::Opus,
        RuntimeProxyClaudeModelAlias::Sonnet,
        RuntimeProxyClaudeModelAlias::Haiku,
    ] {
        let descriptor = runtime_proxy_claude_alias_model(alias);
        let (model_key, name_key, description_key, caps_key) =
            runtime_proxy_claude_alias_env_keys(alias);
        env.push((model_key, OsString::from(descriptor.id)));
        env.push((name_key, OsString::from(descriptor.id)));
        env.push((description_key, OsString::from(descriptor.description)));
        env.push((
            caps_key,
            OsString::from(runtime_proxy_responses_model_capabilities(descriptor.id)),
        ));
    }
    env
}

pub(crate) fn runtime_proxy_claude_picker_model(target_model: &str) -> String {
    runtime_proxy_responses_model_descriptor(target_model)
        .map(|descriptor| {
            if runtime_proxy_claude_use_foundry_compat() {
                descriptor.claude_picker_value()
            } else {
                descriptor.id
            }
        })
        .unwrap_or(target_model)
        .to_string()
}

pub(crate) fn runtime_proxy_claude_custom_model_option_env(
    target_model: &str,
) -> Vec<(&'static str, OsString)> {
    if runtime_proxy_responses_model_descriptor(target_model).is_some() {
        return Vec::new();
    }

    let descriptor = runtime_proxy_responses_model_descriptor(target_model);
    let display_name = descriptor
        .map(|descriptor| descriptor.display_name)
        .unwrap_or(target_model);
    let description = descriptor
        .map(|descriptor| descriptor.description.to_string())
        .unwrap_or_else(|| format!("Custom OpenAI model routed through prodex ({target_model})"));

    vec![
        (
            "ANTHROPIC_CUSTOM_MODEL_OPTION",
            OsString::from(target_model),
        ),
        (
            "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME",
            OsString::from(display_name),
        ),
        (
            "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION",
            OsString::from(description),
        ),
    ]
}

pub(crate) fn runtime_proxy_claude_additional_model_option_entries() -> Vec<serde_json::Value> {
    runtime_proxy_responses_model_descriptors()
        .iter()
        .filter(|descriptor| {
            !(runtime_proxy_claude_use_foundry_compat() && descriptor.claude_alias.is_some())
        })
        .map(|descriptor| {
            let supported_effort_levels =
                runtime_proxy_responses_model_supported_effort_levels(descriptor.id);
            serde_json::json!({
                "value": descriptor.id,
                "label": descriptor.id,
                "description": descriptor.description,
                "supportsEffort": true,
                "supportedEffortLevels": supported_effort_levels,
            })
        })
        .collect()
}

pub(crate) fn runtime_proxy_claude_managed_model_option_value(value: &str) -> bool {
    runtime_proxy_claude_picker_model_descriptor(value).is_some()
}

pub(crate) fn runtime_proxy_responses_model_descriptors()
-> &'static [RuntimeProxyResponsesModelDescriptor] {
    &[
        RuntimeProxyResponsesModelDescriptor {
            id: "gpt-5.4",
            display_name: "GPT-5.4",
            description: "Latest frontier agentic coding model.",
            claude_alias: Some(RuntimeProxyClaudeModelAlias::Opus),
            legacy_claude_picker_model: Some("claude-opus-4-6"),
            supports_xhigh: true,
        },
        RuntimeProxyResponsesModelDescriptor {
            id: "gpt-5.4-mini",
            display_name: "GPT-5.4 Mini",
            description: "Smaller frontier agentic coding model.",
            claude_alias: Some(RuntimeProxyClaudeModelAlias::Haiku),
            legacy_claude_picker_model: Some("claude-haiku-4-5"),
            supports_xhigh: true,
        },
        RuntimeProxyResponsesModelDescriptor {
            id: "gpt-5.3-codex",
            display_name: "GPT-5.3 Codex",
            description: "Frontier Codex-optimized agentic coding model.",
            claude_alias: Some(RuntimeProxyClaudeModelAlias::Sonnet),
            legacy_claude_picker_model: Some("claude-sonnet-4-6"),
            supports_xhigh: true,
        },
        RuntimeProxyResponsesModelDescriptor {
            id: "gpt-5.2",
            display_name: "GPT-5.2",
            description: "Optimized for professional work and long-running agents.",
            claude_alias: None,
            legacy_claude_picker_model: Some("claude-opus-4-5"),
            supports_xhigh: true,
        },
    ]
}

pub(crate) fn runtime_proxy_claude_target_model(requested_model: &str) -> String {
    if let Some(override_model) = runtime_proxy_claude_model_override() {
        return override_model;
    }

    let normalized = requested_model.trim();
    if let Some(descriptor) = runtime_proxy_responses_model_descriptor(normalized) {
        return descriptor.id.to_string();
    }
    if let Some(descriptor) = runtime_proxy_claude_picker_model_descriptor(normalized) {
        return descriptor.id.to_string();
    }
    let lower = normalized.to_ascii_lowercase();
    if lower.starts_with("gpt-")
        || lower.starts_with("o1")
        || lower.starts_with("o3")
        || lower.starts_with("o4")
        || lower.contains("codex")
    {
        normalized.to_string()
    } else if lower == "best" || lower == "default" || lower.contains("opus") {
        runtime_proxy_claude_alias_model(RuntimeProxyClaudeModelAlias::Opus)
            .id
            .to_string()
    } else if lower.contains("sonnet") {
        runtime_proxy_claude_alias_model(RuntimeProxyClaudeModelAlias::Sonnet)
            .id
            .to_string()
    } else if lower.contains("haiku") {
        runtime_proxy_claude_alias_model(RuntimeProxyClaudeModelAlias::Haiku)
            .id
            .to_string()
    } else {
        DEFAULT_PRODEX_CLAUDE_MODEL.to_string()
    }
}

pub(crate) fn runtime_proxy_responses_model_supports_native_computer_tool(model_id: &str) -> bool {
    model_id.trim().eq_ignore_ascii_case("gpt-5.4")
}

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

    #[test]
    fn model_catalog_matches_upstream_rust_0_123_0() {
        let ids = runtime_proxy_responses_model_descriptors()
            .iter()
            .map(|descriptor| descriptor.id)
            .collect::<Vec<_>>();

        assert_eq!(
            ids,
            vec!["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"]
        );
        assert!(!ids.contains(&"gpt-5.2-codex"));
        assert!(!ids.contains(&"gpt-5"));
    }
}