Extract native OpenAI auth spine
This commit is contained in:
parent
ef46f7f5f1
commit
e2e37e550d
10 changed files with 3071 additions and 63 deletions
2247
epiphany-openai-auth-spine/Cargo.lock
generated
Normal file
2247
epiphany-openai-auth-spine/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
epiphany-openai-auth-spine/Cargo.toml
Normal file
19
epiphany-openai-auth-spine/Cargo.toml
Normal 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"
|
||||
732
epiphany-openai-auth-spine/src/lib.rs
Normal file
732
epiphany-openai-auth-spine/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}))?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Reference in a new issue