Extract native OpenAI auth spine

This commit is contained in:
Meta Vens 2026-05-14 08:12:28 +01:00
commit e2e37e550d
10 changed files with 3071 additions and 63 deletions

2247
epiphany-openai-auth-spine/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
[package]
name = "epiphany-openai-auth-spine"
version = "0.1.0"
edition = "2024"
[lib]
name = "epiphany_openai_auth_spine"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
codex-client = { path = "../vendor/codex/codex-rs/codex-client" }
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tracing = "0.1"

View file

@ -0,0 +1,732 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use base64::Engine;
use chrono::DateTime;
use chrono::Utc;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::with_chatgpt_cloudflare_cookie_store;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use thiserror::Error;
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE";
const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const TOKEN_REFRESH_INTERVAL_MINUTES: i64 = 8;
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthCredentialsStoreMode {
#[default]
File,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
ApiKey,
Chatgpt,
#[serde(rename = "chatgptAuthTokens")]
ChatgptAuthTokens,
#[serde(rename = "agentIdentity")]
AgentIdentity,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum AccountPlanType {
#[default]
Free,
Go,
Plus,
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
Business,
#[serde(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
Enterprise,
Edu,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatGptPlanType {
Known(KnownChatGptPlan),
Unknown(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KnownChatGptPlan {
Free,
Go,
Plus,
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
Business,
#[serde(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
#[serde(alias = "hc")]
Enterprise,
Edu,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
pub struct TokenData {
#[serde(
deserialize_with = "deserialize_id_token",
serialize_with = "serialize_id_token"
)]
pub id_token: IdTokenInfo,
pub access_token: String,
pub refresh_token: String,
pub account_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct IdTokenInfo {
pub email: Option<String>,
pub chatgpt_plan_type: Option<ChatGptPlanType>,
pub chatgpt_user_id: Option<String>,
pub chatgpt_account_id: Option<String>,
pub chatgpt_account_is_fedramp: bool,
pub raw_jwt: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct AuthDotJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<AuthMode>,
#[serde(rename = "OPENAI_API_KEY")]
pub openai_api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens: Option<TokenData>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_refresh: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_identity: Option<Value>,
}
#[derive(Debug, Clone)]
pub enum CodexAuth {
ApiKey(ApiKeyAuth),
Chatgpt(ChatgptAuth),
ChatgptAuthTokens(ChatgptAuthTokens),
AgentIdentity(AgentIdentityAuth),
}
#[derive(Debug, Clone)]
pub struct ApiKeyAuth {
api_key: String,
}
#[derive(Debug, Clone)]
pub struct ChatgptAuth {
auth: AuthDotJson,
codex_home: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ChatgptAuthTokens {
auth: AuthDotJson,
}
#[derive(Debug, Clone)]
pub struct AgentIdentityAuth {
account_id: Option<String>,
plan_type: Option<AccountPlanType>,
chatgpt_account_is_fedramp: bool,
}
impl CodexAuth {
pub fn from_api_key(api_key: impl Into<String>) -> Self {
Self::ApiKey(ApiKeyAuth {
api_key: api_key.into(),
})
}
pub fn is_api_key_auth(&self) -> bool {
matches!(self, Self::ApiKey(_))
}
pub fn is_chatgpt_auth(&self) -> bool {
matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_))
}
pub fn auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
Self::Chatgpt(_) => AuthMode::Chatgpt,
Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens,
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
}
}
pub fn uses_codex_backend(&self) -> bool {
matches!(
self,
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_)
)
}
pub fn get_token(&self) -> std::io::Result<String> {
match self {
Self::ApiKey(auth) => Ok(auth.api_key.clone()),
Self::Chatgpt(auth) => auth.access_token(),
Self::ChatgptAuthTokens(auth) => auth.access_token(),
Self::AgentIdentity(_) => Err(std::io::Error::other(
"agent identity auth is not implemented in Epiphany auth spine",
)),
}
}
pub fn get_account_id(&self) -> Option<String> {
match self {
Self::ApiKey(_) => None,
Self::Chatgpt(auth) => auth.account_id(),
Self::ChatgptAuthTokens(auth) => auth.account_id(),
Self::AgentIdentity(auth) => auth.account_id.clone(),
}
}
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
match self {
Self::ApiKey(_) => None,
Self::Chatgpt(auth) => auth.account_plan_type(),
Self::ChatgptAuthTokens(auth) => auth.account_plan_type(),
Self::AgentIdentity(auth) => auth.plan_type,
}
}
pub fn is_fedramp_account(&self) -> bool {
match self {
Self::ApiKey(_) => false,
Self::Chatgpt(auth) => auth.is_fedramp_account(),
Self::ChatgptAuthTokens(auth) => auth.is_fedramp_account(),
Self::AgentIdentity(auth) => auth.chatgpt_account_is_fedramp,
}
}
}
impl ChatgptAuth {
fn access_token(&self) -> std::io::Result<String> {
let tokens = self.tokens()?;
let should_refresh = match self.auth.last_refresh {
Some(last_refresh) => {
Utc::now().signed_duration_since(last_refresh).num_minutes()
>= TOKEN_REFRESH_INTERVAL_MINUTES
}
None => true,
};
if should_refresh && !tokens.refresh_token.is_empty() {
let previous_access_token = tokens.access_token.clone();
return refresh_and_persist_tokens(
self.codex_home.clone(),
tokens.refresh_token.clone(),
)
.map(|auth| {
auth.tokens
.map(|tokens| tokens.access_token)
.unwrap_or(previous_access_token)
});
}
Ok(tokens.access_token.clone())
}
fn tokens(&self) -> std::io::Result<&TokenData> {
self.auth
.tokens
.as_ref()
.ok_or_else(|| std::io::Error::other("ChatGPT token data is not available"))
}
fn account_id(&self) -> Option<String> {
token_account_id(self.auth.tokens.as_ref())
}
fn account_plan_type(&self) -> Option<AccountPlanType> {
token_account_plan_type(self.auth.tokens.as_ref())
}
fn is_fedramp_account(&self) -> bool {
token_is_fedramp(self.auth.tokens.as_ref())
}
}
impl ChatgptAuthTokens {
fn access_token(&self) -> std::io::Result<String> {
self.auth
.tokens
.as_ref()
.map(|tokens| tokens.access_token.clone())
.ok_or_else(|| std::io::Error::other("ChatGPT token data is not available"))
}
fn account_id(&self) -> Option<String> {
token_account_id(self.auth.tokens.as_ref())
}
fn account_plan_type(&self) -> Option<AccountPlanType> {
token_account_plan_type(self.auth.tokens.as_ref())
}
fn is_fedramp_account(&self) -> bool {
token_is_fedramp(self.auth.tokens.as_ref())
}
}
#[derive(Debug)]
pub struct AuthManager {
codex_home: PathBuf,
enable_codex_api_key_env: bool,
cached_auth: RwLock<Option<CodexAuth>>,
}
impl AuthManager {
pub fn shared(
codex_home: PathBuf,
enable_codex_api_key_env: bool,
_auth_credentials_store_mode: AuthCredentialsStoreMode,
_chatgpt_base_url: Option<String>,
) -> Arc<Self> {
Arc::new(Self::new(codex_home, enable_codex_api_key_env))
}
pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self {
let cached_auth = load_auth(&codex_home, enable_codex_api_key_env)
.ok()
.flatten();
Self {
codex_home,
enable_codex_api_key_env,
cached_auth: RwLock::new(cached_auth),
}
}
pub async fn auth(&self) -> Option<CodexAuth> {
if self.enable_codex_api_key_env
&& let Some(api_key) = read_codex_api_key_from_env()
{
return Some(CodexAuth::from_api_key(api_key));
}
if let Ok(guard) = self.cached_auth.read()
&& let Some(auth) = guard.clone()
{
return Some(auth);
}
let auth = load_auth(&self.codex_home, self.enable_codex_api_key_env)
.ok()
.flatten();
if let Ok(mut guard) = self.cached_auth.write() {
*guard = auth.clone();
}
auth
}
pub fn auth_mode(&self) -> Option<AuthMode> {
if self.enable_codex_api_key_env && read_codex_api_key_from_env().is_some() {
return Some(AuthMode::ApiKey);
}
self.cached_auth
.read()
.ok()
.and_then(|guard| guard.as_ref().map(CodexAuth::auth_mode))
}
pub fn codex_api_key_env_enabled(&self) -> bool {
self.enable_codex_api_key_env
}
}
pub fn read_openai_api_key_from_env() -> Option<String> {
read_trimmed_env(OPENAI_API_KEY_ENV_VAR)
}
pub fn read_codex_api_key_from_env() -> Option<String> {
read_trimmed_env(CODEX_API_KEY_ENV_VAR)
}
fn read_trimmed_env(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn load_auth(
codex_home: &PathBuf,
enable_codex_api_key_env: bool,
) -> std::io::Result<Option<CodexAuth>> {
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
return Ok(Some(CodexAuth::from_api_key(api_key)));
}
if let Some(api_key) = read_openai_api_key_from_env() {
return Ok(Some(CodexAuth::from_api_key(api_key)));
}
let auth_path = codex_home.join("auth.json");
let contents = match std::fs::read_to_string(&auth_path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
build_auth(codex_home.clone(), auth_dot_json).map(Some)
}
fn build_auth(codex_home: PathBuf, auth_dot_json: AuthDotJson) -> std::io::Result<CodexAuth> {
if let Some(api_key) = auth_dot_json.openai_api_key.as_ref() {
return Ok(CodexAuth::from_api_key(api_key.clone()));
}
match auth_dot_json.auth_mode.unwrap_or(AuthMode::Chatgpt) {
AuthMode::ApiKey => auth_dot_json
.openai_api_key
.map(CodexAuth::from_api_key)
.ok_or_else(|| std::io::Error::other("OpenAI API key is not available")),
AuthMode::Chatgpt => Ok(CodexAuth::Chatgpt(ChatgptAuth {
auth: auth_dot_json,
codex_home,
})),
AuthMode::ChatgptAuthTokens => Ok(CodexAuth::ChatgptAuthTokens(ChatgptAuthTokens {
auth: auth_dot_json,
})),
AuthMode::AgentIdentity => Ok(CodexAuth::AgentIdentity(AgentIdentityAuth {
account_id: None,
plan_type: None,
chatgpt_account_is_fedramp: false,
})),
}
}
fn token_account_id(tokens: Option<&TokenData>) -> Option<String> {
tokens.and_then(|tokens| {
tokens
.account_id
.clone()
.or_else(|| tokens.id_token.chatgpt_account_id.clone())
})
}
fn token_account_plan_type(tokens: Option<&TokenData>) -> Option<AccountPlanType> {
let plan = tokens.and_then(|tokens| tokens.id_token.chatgpt_plan_type.as_ref())?;
match plan {
ChatGptPlanType::Known(KnownChatGptPlan::Free) => Some(AccountPlanType::Free),
ChatGptPlanType::Known(KnownChatGptPlan::Go) => Some(AccountPlanType::Go),
ChatGptPlanType::Known(KnownChatGptPlan::Plus) => Some(AccountPlanType::Plus),
ChatGptPlanType::Known(KnownChatGptPlan::Pro) => Some(AccountPlanType::Pro),
ChatGptPlanType::Known(KnownChatGptPlan::ProLite) => Some(AccountPlanType::ProLite),
ChatGptPlanType::Known(KnownChatGptPlan::Team) => Some(AccountPlanType::Team),
ChatGptPlanType::Known(KnownChatGptPlan::SelfServeBusinessUsageBased) => {
Some(AccountPlanType::SelfServeBusinessUsageBased)
}
ChatGptPlanType::Known(KnownChatGptPlan::Business) => Some(AccountPlanType::Business),
ChatGptPlanType::Known(KnownChatGptPlan::EnterpriseCbpUsageBased) => {
Some(AccountPlanType::EnterpriseCbpUsageBased)
}
ChatGptPlanType::Known(KnownChatGptPlan::Enterprise) => Some(AccountPlanType::Enterprise),
ChatGptPlanType::Known(KnownChatGptPlan::Edu) => Some(AccountPlanType::Edu),
ChatGptPlanType::Unknown(_) => Some(AccountPlanType::Unknown),
}
}
fn token_is_fedramp(tokens: Option<&TokenData>) -> bool {
tokens
.map(|tokens| tokens.id_token.chatgpt_account_is_fedramp)
.unwrap_or(false)
}
fn refresh_and_persist_tokens(
codex_home: PathBuf,
refresh_token: String,
) -> std::io::Result<AuthDotJson> {
let response = request_chatgpt_token_refresh_blocking(refresh_token)?;
let auth_path = codex_home.join("auth.json");
let contents = std::fs::read_to_string(&auth_path)?;
let mut auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
if let Some(id_token) = response.id_token {
tokens.id_token = parse_chatgpt_jwt_claims(&id_token).map_err(std::io::Error::other)?;
}
if let Some(access_token) = response.access_token {
tokens.access_token = access_token;
}
if let Some(refresh_token) = response.refresh_token {
tokens.refresh_token = refresh_token;
}
auth_dot_json.last_refresh = Some(Utc::now());
std::fs::write(&auth_path, serde_json::to_string_pretty(&auth_dot_json)?)?;
Ok(auth_dot_json)
}
fn request_chatgpt_token_refresh_blocking(
refresh_token: String,
) -> std::io::Result<RefreshResponse> {
let refresh_request = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token,
};
let endpoint = std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR)
.unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string());
let client = reqwest::blocking::Client::builder()
.user_agent(format!(
"epiphany-openai-auth-spine/{}",
env!("CARGO_PKG_VERSION")
))
.build()
.map_err(std::io::Error::other)?;
let response = client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&refresh_request)
.send()
.map_err(std::io::Error::other)?;
let status = response.status();
if status.is_success() {
return response
.json::<RefreshResponse>()
.map_err(std::io::Error::other);
}
let body = response.text().unwrap_or_default();
if status == StatusCode::UNAUTHORIZED {
return Err(std::io::Error::other(classify_refresh_token_failure(&body)));
}
Err(std::io::Error::other(format!(
"failed to refresh ChatGPT token: {status}: {}",
try_parse_error_message(&body)
)))
}
#[derive(Serialize)]
struct RefreshRequest {
client_id: &'static str,
grant_type: &'static str,
refresh_token: String,
}
#[derive(Deserialize, Clone)]
struct RefreshResponse {
id_token: Option<String>,
access_token: Option<String>,
refresh_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{message}")]
pub struct RefreshTokenFailedError {
pub reason: RefreshTokenFailedReason,
pub message: String,
}
impl RefreshTokenFailedError {
pub fn new(reason: RefreshTokenFailedReason, message: impl Into<String>) -> Self {
Self {
reason,
message: message.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshTokenFailedReason {
Expired,
Exhausted,
Revoked,
Other,
}
fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError {
let code = extract_refresh_token_error_code(body);
let reason = match code.as_deref().map(str::to_ascii_lowercase).as_deref() {
Some("refresh_token_expired") => RefreshTokenFailedReason::Expired,
Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted,
Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked,
_ => RefreshTokenFailedReason::Other,
};
RefreshTokenFailedError::new(reason, "ChatGPT refresh token could not be refreshed")
}
fn extract_refresh_token_error_code(body: &str) -> Option<String> {
let Value::Object(map) = serde_json::from_str::<Value>(body).ok()? else {
return None;
};
if let Some(error_value) = map.get("error") {
match error_value {
Value::Object(obj) => {
if let Some(code) = obj.get("code").and_then(Value::as_str) {
return Some(code.to_string());
}
}
Value::String(code) => return Some(code.to_string()),
_ => {}
}
}
map.get("code").and_then(Value::as_str).map(str::to_string)
}
fn try_parse_error_message(body: &str) -> String {
serde_json::from_str::<Value>(body)
.ok()
.and_then(|value| value.get("error").cloned())
.and_then(|error| match error {
Value::String(message) => Some(message),
Value::Object(mut map) => map
.remove("message")
.and_then(|message| message.as_str().map(ToString::to_string)),
_ => None,
})
.unwrap_or_else(|| body.to_string())
}
#[derive(Deserialize)]
struct IdClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/profile", default)]
profile: Option<ProfileClaims>,
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct ProfileClaims {
#[serde(default)]
email: Option<String>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<ChatGptPlanType>,
#[serde(default)]
chatgpt_user_id: Option<String>,
#[serde(default)]
user_id: Option<String>,
#[serde(default)]
chatgpt_account_id: Option<String>,
#[serde(default)]
chatgpt_account_is_fedramp: bool,
}
#[derive(Debug, Error)]
pub enum IdTokenInfoError {
#[error("invalid ID token format")]
InvalidFormat,
#[error(transparent)]
Base64(#[from] base64::DecodeError),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn decode_jwt_payload<T: DeserializeOwned>(jwt: &str) -> Result<T, IdTokenInfoError> {
let mut parts = jwt.split('.');
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return Err(IdTokenInfoError::InvalidFormat),
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
Ok(serde_json::from_slice(&payload_bytes)?)
}
pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
let claims: IdClaims = decode_jwt_payload(jwt)?;
let email = claims
.email
.or_else(|| claims.profile.and_then(|profile| profile.email));
match claims.auth {
Some(auth) => Ok(IdTokenInfo {
email,
raw_jwt: jwt.to_string(),
chatgpt_plan_type: auth.chatgpt_plan_type,
chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id),
chatgpt_account_id: auth.chatgpt_account_id,
chatgpt_account_is_fedramp: auth.chatgpt_account_is_fedramp,
}),
None => Ok(IdTokenInfo {
email,
raw_jwt: jwt.to_string(),
chatgpt_plan_type: None,
chatgpt_user_id: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
}),
}
}
fn deserialize_id_token<'de, D>(deserializer: D) -> Result<IdTokenInfo, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
parse_chatgpt_jwt_claims(&value).map_err(serde::de::Error::custom)
}
fn serialize_id_token<S>(id_token: &IdTokenInfo, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&id_token.raw_jwt)
}
pub mod default_client {
use super::*;
pub fn build_reqwest_client() -> reqwest::Client {
try_build_reqwest_client().unwrap_or_else(|error| {
tracing::warn!(error = %error, "failed to build Epiphany OpenAI auth client");
with_chatgpt_cloudflare_cookie_store(reqwest::Client::builder())
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
fn try_build_reqwest_client()
-> Result<reqwest::Client, codex_client::BuildCustomCaTransportError> {
let mut headers = HeaderMap::new();
headers.insert("originator", HeaderValue::from_static("epiphany"));
let builder = reqwest::Client::builder()
.user_agent(format!(
"epiphany-openai-auth-spine/{}",
env!("CARGO_PKG_VERSION")
))
.default_headers(headers);
build_reqwest_client_with_custom_ca(with_chatgpt_cloudflare_cookie_store(builder))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_key_auth_exposes_openai_token() {
let auth = CodexAuth::from_api_key("sk-test");
assert!(auth.is_api_key_auth());
assert_eq!(auth.get_token().unwrap(), "sk-test");
assert!(!auth.uses_codex_backend());
}
#[test]
fn parses_minimal_api_key_auth_json() {
let auth: AuthDotJson =
serde_json::from_str(r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-test"}"#).unwrap();
let built = build_auth(PathBuf::from("."), auth).unwrap();
assert_eq!(built.auth_mode(), AuthMode::ApiKey);
}
}

View file

@ -12,8 +12,8 @@ path = "src/lib.rs"
[dependencies]
anyhow = "1"
codex-client = { path = "../vendor/codex/codex-rs/codex-client" }
codex-login = { path = "../vendor/codex/codex-rs/login" }
epiphany-openai-adapter = { path = "../epiphany-openai-adapter" }
epiphany-openai-auth-spine = { path = "../epiphany-openai-auth-spine" }
futures = "0.3"
http = "1"
serde = "1"

View file

@ -5,13 +5,13 @@ use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_login::CodexAuth;
use epiphany_openai_adapter::EpiphanyOpenAiInputItem;
use epiphany_openai_adapter::EpiphanyOpenAiModelRequest;
use epiphany_openai_auth_spine::CodexAuth;
use epiphany_openai_codex_spine::EpiphanyCodexOpenAiTransport;
use epiphany_openai_codex_spine::auth_manager;
use epiphany_openai_codex_spine::default_codex_home;
use epiphany_openai_codex_spine::responses_request_from_epiphany;
use epiphany_openai_codex_spine::responses_body_from_epiphany;
use epiphany_openai_codex_spine::status_from_auth_manager;
use epiphany_openai_codex_spine::status_from_codex_auth;
use serde_json::json;
@ -54,11 +54,11 @@ async fn main() -> Result<()> {
request.input.push(EpiphanyOpenAiInputItem::UserText {
text: "smoke".to_string(),
});
let mapped = responses_request_from_epiphany(request)?;
let mapped = responses_body_from_epiphany(request)?;
print_json(&json!({
"status": status,
"mappedModel": mapped.model,
"mappedInputItems": mapped.input.len(),
"mappedModel": mapped.get("model"),
"mappedInputItems": mapped.get("input").and_then(serde_json::Value::as_array).map(Vec::len),
"transport": "not-opened"
}))?;
}

View file

@ -8,11 +8,6 @@ use codex_client::Request;
use codex_client::ReqwestTransport;
use codex_client::TransportError;
use codex_client::sse_stream;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use epiphany_openai_adapter::EpiphanyOpenAiAdapterStatus;
use epiphany_openai_adapter::EpiphanyOpenAiAuthMode;
use epiphany_openai_adapter::EpiphanyOpenAiInputItem;
@ -21,6 +16,11 @@ use epiphany_openai_adapter::EpiphanyOpenAiModelRequest;
use epiphany_openai_adapter::EpiphanyOpenAiStreamEvent;
use epiphany_openai_adapter::EpiphanyOpenAiStreamPayload;
use epiphany_openai_adapter::OPENAI_ADAPTER_STATUS_SCHEMA_ID;
use epiphany_openai_auth_spine::AuthCredentialsStoreMode;
use epiphany_openai_auth_spine::AuthManager;
use epiphany_openai_auth_spine::AuthMode;
use epiphany_openai_auth_spine::CodexAuth;
use epiphany_openai_auth_spine::default_client::build_reqwest_client;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
@ -549,7 +549,7 @@ mod tests {
}
#[test]
fn maps_typed_request_to_responses_body_without_codex_protocol_cargo() {
fn maps_typed_request_to_responses_body_without_codex_protocol_payload() {
let mut request = EpiphanyOpenAiModelRequest::new(
"req-1",
"conversation-1",

View file

@ -28,16 +28,16 @@ here is suspect until proven otherwise.
### Transformations
- `codex-login` loads auth, refreshes ChatGPT access tokens, preserves account
metadata, and exposes `CodexAuth`.
- `epiphany-openai-auth-spine` loads file/env auth, refreshes ChatGPT access
tokens, preserves account metadata, and exposes the auth subset needed by the
Epiphany OpenAI transport.
- `epiphany-openai-codex-spine` converts `CodexAuth` directly into
authorization/account headers, chooses the ChatGPT Codex backend or OpenAI API
base URL from auth mode, builds a local serializable Responses request body,
opens an HTTP/SSE stream through `codex-client`, and parses Responses frames
into typed Epiphany stream events.
- `codex-login` still drags `codex-config`, `codex-model-provider-info`,
`codex-api`, and `codex-protocol` transitively. That is dependency rot, not
current request-path ownership.
- `codex-login` remains in `vendor/codex` as reference/legacy Codex host
machinery, but it is no longer in the native Epiphany OpenAI spine graph.
### Outputs
@ -50,10 +50,10 @@ here is suspect until proven otherwise.
## Keep
- `login/src/auth/manager.rs`: keep as the initial credential compatibility
organ. It knows ChatGPT/API-key/agent/external auth modes, refresh semantics,
token storage, and account metadata. Long term, wrap it behind an Epiphany
`OpenAIAuthAdapter` instead of letting the rest of Epiphany import it.
- `epiphany-openai-auth-spine`: keep as the native credential compatibility
organ. It knows the path Epiphany actually uses now: `CODEX_API_KEY` /
`OPENAI_API_KEY`, `$CODEX_HOME/auth.json`, ChatGPT token refresh, account id,
plan metadata, FedRAMP flag, and the standard HTTP client edge.
- `codex-client`: keep only as a plain HTTP/SSE transport helper for now. It
does not own Epiphany request shape, model policy, stream semantics, or durable
state.
@ -70,9 +70,13 @@ here is suspect until proven otherwise.
suspicious unless Epiphany deliberately needs that OpenAI endpoint. Keep them
sealed with the transport until the native adapter split can decide.
- `codex-api`, `codex-model-provider`, `codex-model-provider-info`,
`codex-core::client`, and `models-manager` are no longer part of the direct
OpenAI request path. If a later model catalog is needed, it should be a typed
Epiphany catalog document, not a revival of the old Codex provider stack.
`codex-login`, `codex-core::client`, and `models-manager` are no longer part
of the native OpenAI spine graph. If a later model catalog is needed, it
should be a typed Epiphany catalog document, not a revival of the old Codex
provider stack.
- Native auth does not yet preserve every Codex auth mode. Keyring storage,
external bearer command auth, and agent identity are legacy/reference gaps
unless a concrete user path proves they are needed.
## Extract Target
@ -89,8 +93,8 @@ EpiphanyOpenAIModelTransport
-> emit typed stream events / receipts
```
The adapter may depend on `codex-login` and `codex-client` only while the
reliquary survives. It must not depend on
The adapter may depend on `epiphany-openai-auth-spine` and `codex-client` only
while the reliquary survives. It must not depend on
`codex-app-server`, `codex_message_processor`, plugin/app/skill/marketplace
modules, Codex provider/request construction, or Epiphany state ownership.
@ -118,12 +122,13 @@ pure document crate depend on Codex.
The transport wrapper is now also in that spine. It maps typed
`EpiphanyOpenAiModelRequest` documents into a local serializable Responses
request body, resolves credentials through `codex-login`, chooses the
request body, resolves credentials through `epiphany-openai-auth-spine`, chooses the
ChatGPT/OpenAI base URL from auth mode, opens an HTTP Responses SSE stream with
`codex-client`, and converts stream deltas/completion into typed
`EpiphanyOpenAiStreamEvent` / `EpiphanyOpenAiModelReceipt` documents. It no
longer directly imports Codex `ResponsesApiRequest`, `ResponsesClient`,
`ResponseEvent`, provider config, model-provider, or protocol enums.
longer directly or transitively imports `codex-login`, Codex
`ResponsesApiRequest`, `ResponsesClient`, `ResponseEvent`, provider config,
model-provider, app-server protocol, or protocol enums.
The CultNet paperwork is now public too. `schemas/cultnet/` contains typed
schemas for OpenAI adapter status, model request, stream event, and terminal
@ -171,13 +176,13 @@ application. `codex-core` re-exports the contract only as a compatibility
alias, and `CodexThread` now calls native state-update functions around its
remaining revision check, persistence validation, and rollout/session writeback.
The bad news is useful: `cargo tree -i codex-api` still shows `codex-api` and
`codex-protocol` entering through `codex-login`'s dependency graph
(`codex-config`, `codex-model-provider-info`, and `codex-otel`). That is not
proof the request path needs them. It is proof the credential organ is still
fat. The next purification should extract or rewrite the minimum auth surface:
credential loading, token refresh, account metadata, and header material needed
to keep Codex subscription/API-key compatibility.
The first credential extraction is now landed: `epiphany-openai-auth-spine`
replaces `codex-login` in the native OpenAI path for file/env auth, ChatGPT
token refresh, account metadata, and header material. `cargo tree` for
`epiphany-openai-codex-spine` now finds no `codex-login`, `codex-api`,
`codex-protocol`, `codex-app-server-protocol`, or model-provider crates. The
remaining question is whether keyring/external bearer/agent-identity support
deserves native implementation or should remain a sealed legacy Codex path.
The point is ownership: Epiphany calls a model adapter; it does not live inside
the Codex host brain.

View file

@ -34,11 +34,11 @@ remembers doctrine; the branch remembers the blade.
- Do not copy exact branch or HEAD from this note. Run `git status --short --branch` and `git log --oneline -5`.
- The active foundation directive is now `notes/codex-starvation-and-cultnet-liberation-plan.md`. The previous Codex app-server control-plane rebuild made Epiphany less rotten inside Codex, but that is not the destination. Epiphany must become a native CultCache/CultNet runtime. Codex is a compatibility reliquary for OpenAI subscription auth and model transport, not the host brain.
- `notes/codex-auth-spine-inventory.md` is the source-grounded keeper list for that reliquary: retain `codex-login` credential storage/refresh only while it preserves Codex subscription/API-key compatibility, and keep `codex-client` only as a plain HTTP/SSE helper. `codex-api`, `codex-model-provider`, `codex-model-provider-info`, `codex-core::client`, and `models-manager` are no longer keeper request-path organs; if model catalog/routing grows back, it should be an Epiphany typed document surface.
- `notes/codex-auth-spine-inventory.md` is the source-grounded keeper list for that reliquary: `epiphany-openai-auth-spine` is now the native credential organ for file/env auth, ChatGPT token refresh, account metadata, and header material; `codex-client` remains only as a plain HTTP/SSE helper. `codex-login`, `codex-api`, `codex-model-provider`, `codex-model-provider-info`, `codex-core::client`, and `models-manager` are no longer keeper request-path organs; if model catalog/routing grows back, it should be an Epiphany typed document surface.
- Edge JSON is allowed for schema description, hostile ingress before immediate typed parsing, sealed forensic artifacts, or named quarantine experiments. When both subsystems are ours, runtime data must remain typed CultCache documents and move over CultNet typed contracts. Generic `serde_json::Value` in worker launch/result/selfPatch/runtime flow is contamination until classified and replaced.
- `epiphany-openai-runtime` worker result ingress now parses assistant output directly into typed role/reorient ingress structs selected from the durable launch document, stores nested `statePatch` and `selfPatch` as typed MessagePack in `EpiphanyRuntimeRoleWorkerResult`, and no longer passes worker result data through generic `serde_json::Value`. JSON survives there only as the OpenAI text-output edge before typed deserialization.
- Agent self-memory review/application now has typed `AgentSelfPatch` core APIs. JSON-value selfPatch functions are compatibility wrappers for CLI/artifact ingress; native callers should parse once at the edge and then call `review_agent_self_patch_document` / `apply_agent_self_patch_document`.
- `epiphany-openai-codex-spine` no longer uses Codex's Responses client, model-provider layer, `codex-api`, `codex-model-provider`, `codex-model-provider-info`, or `codex-protocol` directly for outbound model requests. It keeps `codex-login` for subscription/API-key auth, builds a local serializable OpenAI Responses body, opens the HTTP/SSE stream through `codex-client`, and parses Responses SSE frames into typed Epiphany stream events. `codex-login` still drags `codex-config` / `codex-model-provider-info` / `codex-api` / `codex-protocol` transitively, so the next real spine cut is a minimal auth crate or extracted credential adapter.
- `epiphany-openai-codex-spine` no longer uses Codex's Responses client, model-provider layer, `codex-api`, `codex-model-provider`, `codex-model-provider-info`, `codex-protocol`, or `codex-login` for outbound model requests. It uses `epiphany-openai-auth-spine` for file/env credentials and ChatGPT token refresh, builds a local serializable OpenAI Responses body, opens the HTTP/SSE stream through `codex-client`, and parses Responses SSE frames into typed Epiphany stream events. `cargo tree` for the spine now finds no `codex-login`, `codex-api`, `codex-protocol`, `codex-app-server-protocol`, or model-provider crates. Caveat: keyring storage, external bearer command auth, and agent identity are not native yet.
- Durable Epiphany state types now live in root crate `epiphany-state-model`. `codex-protocol` re-exports them only so old rollout/API callers keep compiling; do not add new durable `Epiphany*` state structs under `vendor/codex`. `epiphany-core` imports the native model directly and no longer depends on `codex-protocol`. Pressure derives from native `EpiphanyTokenUsageSnapshot`; Codex token telemetry is mapped at the bridge. Codex rollout/event reconstruction now lives in `codex-core::epiphany_rollout`, where the Codex `RolloutItem` / `EventMsg` types belong. Exact retrieval path matching is native to `epiphany-core`, so `epiphany-core` has no direct vendored Codex crate dependency.
- The May 2026 foundation migration in `notes/epiphany-ideal-architecture-rebuild-plan.md` is complete enough to defend. Read `notes/epiphany-architectural-teardown.md` before touching Epiphany control-plane code, but do not treat it as an open-ended excuse to keep shaving the altar. The rebuild moved scene projection, freshness derivation, pressure policy, targeted context shards, bounded graph traversal, job/progress view derivation, planning view derivation, reorientation resume/regather verdict policy, pure CRRC recommendation policy, fixed-lane coordinator decision policy, role-board projection policy, role/reorient result interpretation, role self-persistence review policy, and role/reorient acceptance bundle policy into `epiphany-core`; typed acceptance receipts replaced summary-string identity for live accept paths, runtime links are dual-written on launch, and result read-back now prefers runtime links.
- Read `notes/epiphany-ideal-architecture-rebuild-plan.md` immediately after the teardown. It defines the smallest coherent replacement architecture: durable `EpiphanyState`, runtime-spine-owned `RuntimeState`, separate role memory/heartbeat state, `epiphany-core` as policy owner, app-server as adapter, Aquarium as reflector, typed acceptance receipts, and derived view lenses instead of a protocol verb zoo.
@ -377,9 +377,9 @@ inventory and ledger now exist:
- `notes/codex-auth-spine-inventory.md` maps the shrinking Codex
auth/model-call reliquary: `login` auth/token refresh is still kept for
subscription compatibility, `codex-client` is a plain HTTP/SSE edge, and
`codex-api` / `codex-protocol` are no longer directly used by the OpenAI
request path. Their remaining presence comes through `codex-login`'s fat
dependency graph.
`codex-login` / `codex-api` / `codex-protocol` are no longer used by the
native OpenAI request path. The remaining Codex auth gaps are keyring storage,
external bearer command auth, and agent identity.
- `notes/json-contamination-ledger.md` classifies the major Epiphany JSON
contamination. Edge JSON is allowed only for schema/wire boundaries,
hostile ingress before typed parse, sealed forensic artifacts, or named
@ -558,37 +558,41 @@ dependency and no generic JSON payload. A direct standalone `codex-core` path
dependency attempt hit transitive dependency skew outside the vendored Codex
workspace lock; do not "fix" that with random version pins. The first
workspace-verified wrapper now exists as `epiphany-openai-codex-spine`: it
depends on the pure typed adapter plus the keeper Codex auth types and projects
`AuthManager` / `CodexAuth` into a typed `EpiphanyOpenAiAdapterStatus`.
depends on the pure typed adapter plus the Epiphany-owned auth spine and
projects `AuthManager` / `CodexAuth` into a typed
`EpiphanyOpenAiAdapterStatus`.
`epiphany-codex-bridge` re-exports that spine so the current app-server shell
can compile the attachment without contaminating the pure document crate. The
same spine now owns the HTTP Responses transport wrapper without Codex's
Responses client or model-provider layer: typed `EpiphanyOpenAiModelRequest`
documents map into a local serializable Responses body, auth comes from
`codex-login`, the stream opens through `codex-client`, and Responses SSE frames
parse back into typed `EpiphanyOpenAiStreamEvent` and
`EpiphanyOpenAiModelReceipt` documents. The spine no longer directly depends on
`codex-app-server-protocol`, `codex-protocol`, `codex-api`,
`codex-model-provider`, or `codex-model-provider-info`. The CultNet schema
`epiphany-openai-auth-spine`, the stream opens through `codex-client`, and
Responses SSE frames parse back into typed `EpiphanyOpenAiStreamEvent` and
`EpiphanyOpenAiModelReceipt` documents. The spine no longer directly or
transitively depends on `codex-login`, `codex-app-server-protocol`,
`codex-protocol`, `codex-api`, `codex-model-provider`, or
`codex-model-provider-info`. The CultNet schema
catalog advertises OpenAI adapter status, model request, stream event, and
receipt document types plus the coordinator-owned model request contract. The
remaining contamination is `codex-login` itself: it still pulls the fat Codex
config/model-provider/API/protocol stack transitively.
remaining auth caveat is functional, not architectural: keyring storage,
external bearer command auth, and agent identity are not implemented natively
yet.
The `epiphany-openai-spine` binary is now the first native edge for that
wrapper. It can print typed adapter status and consume a serialized
`EpiphanyOpenAiModelRequest` document for a model turn outside Codex
app-server. Treat its JSON as CLI/file-edge serialization of typed documents,
not internal data cargo. The native request path no longer pulls `codex-api`
directly, so the next real cut is not celebration; it is extracting or
rewriting the minimum `codex-login` credential surface so auth compatibility no
longer imports the old Codex config/model-provider/API/protocol stack.
not internal data cargo. The native request path no longer pulls `codex-login`
or `codex-api`; the next real cut is deciding whether keyring/external
bearer/agent-identity auth deserves native implementation or remains sealed
legacy Codex compatibility.
The native runtime no longer imports `codex-login` directly. The Codex spine now
exports the `default_codex_home` and `auth_manager` helpers, so
`epiphany-openai-runtime` reaches Codex credentials only through
`epiphany-openai-codex-spine`. Keep that boundary: if native runtime code needs
credential details, the spine owns the adapter surface.
exports the `default_codex_home` and `auth_manager` helpers, and those helpers
use `epiphany-openai-auth-spine`, so `epiphany-openai-runtime` reaches Codex
credentials only through an Epiphany-owned adapter surface. Keep that boundary:
if native runtime code needs credential details, the auth spine owns the
contract.
That native route has now started. `epiphany-openai-adapter` documents are
CultCache `DatabaseEntry` types, `epiphany-core::runtime_spine_cache` registers
@ -605,8 +609,9 @@ documents, and completes the original heartbeat/specialist runtime job without
Codex worker execution. `roleResult` and `reorientResult` now require those
typed worker result documents; generic runtime job summaries are lifecycle
receipts, not reviewable findings.
The next cut is carving down the remaining `codex-login` dependency weight
without losing ChatGPT subscription/API-key compatibility.
The next cut is no longer `codex-login` weight in this spine; it is either
native keyring/external bearer/agent-identity auth support or the next
Epiphany-in-vendor evacuation surface.
That coordinator cut is now landed for the MVP runner. `epiphany-mvp-coordinator`
accepts `--openai-runtime-bin`, resolves the local `epiphany-openai-runtime`

Binary file not shown.

File diff suppressed because one or more lines are too long