use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use super::{LaunchError, LaunchPlan, LaunchRecipe, ProxyEndpoint, launch_error};
const HARNESS_ID: &str = crate::harness::CODEX.id();
pub const CODEX_API_KEY_ENV: &str = "OPENAI_API_KEY";
pub const OPENAI_BASE_URL_ENV: &str = "OPENAI_BASE_URL";
pub const OPENAI_API_BASE_ENV: &str = "OPENAI_API_BASE";
const FEATURE_DISABLE_COMPRESSION: &str = "features.enable_request_compression=false";
const WIRE_API: &str = "responses";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexAuth {
ApiKey,
ChatGpt,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexRecipe {
endpoint: ProxyEndpoint,
auth: CodexAuth,
provider_id: String,
provider_display_name: Option<String>,
attribution_header: Option<String>,
env_key_instructions: Option<String>,
}
impl CodexRecipe {
pub fn new(endpoint: ProxyEndpoint, auth: CodexAuth, provider_id: impl Into<String>) -> Self {
Self {
endpoint,
auth,
provider_id: provider_id.into(),
provider_display_name: None,
attribution_header: None,
env_key_instructions: None,
}
}
pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
self.provider_display_name = Some(name.into());
self
}
pub fn with_attribution_header(mut self, header: impl Into<String>) -> Self {
self.attribution_header = Some(header.into());
self
}
pub fn with_env_key_instructions(mut self, instructions: impl Into<String>) -> Self {
self.env_key_instructions = Some(instructions.into());
self
}
fn provider_key(&self) -> String {
format!("model_providers.{}", self.provider_id)
}
}
impl LaunchRecipe for CodexRecipe {
fn harness(&self) -> &str {
HARNESS_ID
}
fn plan(&self) -> Result<LaunchPlan, LaunchError> {
if self.provider_id.trim().is_empty() {
return launch_error::EmptyProviderIdSnafu.fail();
}
require_toml_bare_key("codex provider id", &self.provider_id)?;
let provider_id = &self.provider_id;
let provider = self.provider_key();
let mut args = vec![
"-c".to_string(),
format!("model_provider={}", toml_quote_value(provider_id)),
];
if let Some(name) = &self.provider_display_name {
args.push("-c".to_string());
args.push(format!("{provider}.name={}", toml_quote_value(name)));
}
args.push("-c".to_string());
args.push(format!(
"{provider}.base_url={}",
toml_quote_value(&self.endpoint.to_string())
));
args.push("-c".to_string());
args.push(format!("{provider}.wire_api=\"{WIRE_API}\""));
if let Some(header) = &self.attribution_header {
let quoted = toml_quote_key("codex attribution header", header)?;
args.push("-c".to_string());
args.push(format!(
"{provider}.http_headers.{quoted}={}",
toml_quote_value(provider_id)
));
}
match self.auth {
CodexAuth::ApiKey => {
args.push("-c".to_string());
args.push(format!("{provider}.env_key=\"{CODEX_API_KEY_ENV}\""));
if let Some(instructions) = &self.env_key_instructions {
args.push("-c".to_string());
args.push(format!(
"{provider}.env_key_instructions={}",
toml_quote_value(instructions)
));
}
}
CodexAuth::ChatGpt => {
args.push("-c".to_string());
args.push(format!("{provider}.requires_openai_auth=true"));
}
}
args.push("-c".to_string());
args.push(FEATURE_DISABLE_COMPRESSION.to_string());
Ok(LaunchPlan {
args,
env: Vec::new(),
config_files: Vec::new(),
})
}
}
pub fn resolve_codex_auth(env: &HashMap<OsString, OsString>) -> CodexAuth {
if env_has_value(env, CODEX_API_KEY_ENV) {
CodexAuth::ApiKey
} else {
CodexAuth::ChatGpt
}
}
pub fn env_has_value(env: &HashMap<OsString, OsString>, key: &str) -> bool {
env.get(OsString::from(key).as_os_str())
.and_then(|value| value.to_str())
.is_some_and(|value| !value.trim().is_empty())
}
pub fn codex_auth_file() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".codex").join("auth.json"))
}
fn toml_quote_value(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
c if c.is_control() => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
fn toml_quote_key(what: &'static str, key: &str) -> Result<String, LaunchError> {
if !key.is_ascii() || key.chars().any(char::is_control) {
return launch_error::UnrepresentableTomlKeySnafu {
what,
value: key.to_string(),
}
.fail();
}
Ok(format!("{key:?}"))
}
fn require_toml_bare_key(what: &'static str, key: &str) -> Result<(), LaunchError> {
let bare = key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if bare {
return Ok(());
}
launch_error::UnrepresentableTomlKeySnafu {
what,
value: key.to_string(),
}
.fail()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn values_with_quotes_and_backslashes_are_escaped_not_interpolated() {
let quoted = toml_quote_value(r#"Paper "Prod" C:\paper"#);
assert_eq!(quoted, r#""Paper \"Prod\" C:\\paper""#);
}
#[test]
fn control_characters_cannot_smuggle_extra_toml() {
let quoted = toml_quote_value("line\nbreak\u{7f}");
assert_eq!(quoted, r#""line\nbreak\u007F""#);
assert!(!quoted.contains('\n'));
}
fn api_key_recipe() -> CodexRecipe {
CodexRecipe::new(
ProxyEndpoint::new("http://127.0.0.1:51539/v1/openai-responses/openai-transparent/v1"),
CodexAuth::ApiKey,
"paper-openai-test",
)
.with_display_name("Paper OpenAI")
.with_attribution_header("X-Paper-Codex-Attribution")
.with_env_key_instructions(
"Set OPENAI_API_KEY to an OpenAI API key; Paper routes Codex through paperd with your own provider credential.",
)
}
#[test]
fn plan_emits_the_api_key_provider_config_in_order() {
let plan = api_key_recipe().plan().unwrap();
assert_eq!(
plan.args,
vec![
"-c".to_string(),
"model_provider=\"paper-openai-test\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.name=\"Paper OpenAI\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.base_url=\"http://127.0.0.1:51539/v1/openai-responses/openai-transparent/v1\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.wire_api=\"responses\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.http_headers.\"X-Paper-Codex-Attribution\"=\"paper-openai-test\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.env_key=\"OPENAI_API_KEY\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.env_key_instructions=\"Set OPENAI_API_KEY to an OpenAI API key; Paper routes Codex through paperd with your own provider credential.\"".to_string(),
"-c".to_string(),
"features.enable_request_compression=false".to_string(),
],
);
assert!(plan.env.is_empty(), "codex config rides argv, not the env");
assert!(plan.config_files.is_empty());
}
#[test]
fn plan_emits_the_chatgpt_provider_config_in_order() {
let plan = CodexRecipe::new(
ProxyEndpoint::new("http://127.0.0.1:51539/v1/openai-chatgpt/chatgpt-codex"),
CodexAuth::ChatGpt,
"paper-openai-test",
)
.with_display_name("Paper OpenAI")
.with_attribution_header("X-Paper-Codex-Attribution")
.with_env_key_instructions("ignored in chatgpt mode")
.plan()
.unwrap();
assert_eq!(
plan.args,
vec![
"-c".to_string(),
"model_provider=\"paper-openai-test\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.name=\"Paper OpenAI\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.base_url=\"http://127.0.0.1:51539/v1/openai-chatgpt/chatgpt-codex\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.wire_api=\"responses\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.http_headers.\"X-Paper-Codex-Attribution\"=\"paper-openai-test\"".to_string(),
"-c".to_string(),
"model_providers.paper-openai-test.requires_openai_auth=true".to_string(),
"-c".to_string(),
"features.enable_request_compression=false".to_string(),
],
);
assert!(
!plan.args.iter().any(|arg| arg.contains("env_key")),
"ChatGPT mode must not set env_key: {:?}",
plan.args,
);
}
#[test]
fn plan_omits_unset_optional_knobs() {
let plan = CodexRecipe::new(
ProxyEndpoint::new("http://localhost:9/v1"),
CodexAuth::ApiKey,
"tapes-openai",
)
.plan()
.unwrap();
assert_eq!(
plan.args,
vec![
"-c".to_string(),
"model_provider=\"tapes-openai\"".to_string(),
"-c".to_string(),
"model_providers.tapes-openai.base_url=\"http://localhost:9/v1\"".to_string(),
"-c".to_string(),
"model_providers.tapes-openai.wire_api=\"responses\"".to_string(),
"-c".to_string(),
"model_providers.tapes-openai.env_key=\"OPENAI_API_KEY\"".to_string(),
"-c".to_string(),
"features.enable_request_compression=false".to_string(),
],
);
}
#[test]
fn plan_always_disables_request_compression() {
for auth in [CodexAuth::ApiKey, CodexAuth::ChatGpt] {
let plan = CodexRecipe::new(ProxyEndpoint::new("http://localhost:9"), auth, "p")
.plan()
.unwrap();
assert!(
plan.args
.iter()
.any(|arg| arg == "features.enable_request_compression=false"),
"{auth:?} must disable compression: {:?}",
plan.args,
);
}
}
#[test]
fn plan_rejects_a_blank_provider_id() {
let err = CodexRecipe::new(
ProxyEndpoint::new("http://localhost:9"),
CodexAuth::ApiKey,
" ",
)
.plan()
.expect_err("blank provider id must be refused");
assert!(matches!(err, LaunchError::EmptyProviderId), "{err:?}");
}
#[test]
fn plan_rejects_a_provider_id_that_is_not_a_bare_toml_key() {
for bad in ["has.dot", "has space", "has\"quote"] {
let err = CodexRecipe::new(
ProxyEndpoint::new("http://localhost:9"),
CodexAuth::ApiKey,
bad,
)
.plan()
.expect_err("non-bare provider id must be refused");
assert!(
matches!(err, LaunchError::UnrepresentableTomlKey { .. }),
"{bad:?} -> {err:?}",
);
}
}
#[test]
fn plan_rejects_an_unrepresentable_attribution_header() {
for bad in ["X-Café-Attribution", "X-Bad\u{7f}Header"] {
let err = CodexRecipe::new(
ProxyEndpoint::new("http://localhost:9"),
CodexAuth::ApiKey,
"p",
)
.with_attribution_header(bad)
.plan()
.expect_err("unrepresentable header must be refused");
assert!(
matches!(err, LaunchError::UnrepresentableTomlKey { what, .. } if what.contains("header")),
"{bad:?} -> {err:?}",
);
}
}
#[test]
fn resolve_codex_auth_prefers_api_key_then_chatgpt() {
let mut env: HashMap<OsString, OsString> = HashMap::new();
assert_eq!(resolve_codex_auth(&env), CodexAuth::ChatGpt);
env.insert(OsString::from("OPENAI_API_KEY"), OsString::from("sk-x"));
assert_eq!(resolve_codex_auth(&env), CodexAuth::ApiKey);
env.insert(OsString::from("OPENAI_API_KEY"), OsString::from(" "));
assert_eq!(resolve_codex_auth(&env), CodexAuth::ChatGpt);
}
#[test]
fn codex_auth_file_names_the_conventional_path() {
if let Some(path) = codex_auth_file() {
assert!(path.ends_with(".codex/auth.json"), "{path:?}");
}
}
#[test]
fn harness_id_matches_the_envelope_value() {
let recipe = CodexRecipe::new(
ProxyEndpoint::new("http://localhost:9"),
CodexAuth::ApiKey,
"p",
);
assert_eq!(recipe.harness(), tapes_capture::envelope::HARNESS_ID_CODEX);
assert_eq!(recipe.harness(), "codex");
}
}