feat: add Codex credential-reuse provider

This commit is contained in:
Gabriel Scharb
2026-09-11 14:43:20 -03:00
parent 6a375344ac
commit cd7bf7b9d2
16 changed files with 1215 additions and 67 deletions
+2 -1
View File
@@ -291,7 +291,8 @@ The full model is in [`docs/security.md`](docs/security.md),
Optional. Everything works with zero LLM calls; adding a provider
upgrades session summaries and enables semantic search. Anthropic,
OpenAI (incl. OAuth/Codex), GitHub Copilot, Gemini, OpenCode (Go and Zen), and
OpenAI (including OAuth), Codex CLI credential reuse, GitHub Copilot, Gemini,
OpenCode (Go and Zen), and
any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM) are supported
for consolidation; OpenAI, Voyage, Gemini, and keyless OpenAI-compatible
endpoints for embeddings. Configuration lives in
+2
View File
@@ -1696,6 +1696,8 @@ pub enum LlmProviderChoice {
OpenaiCompat,
/// OpenAI ChatGPT/Codex OAuth backend.
OpenaiOauth,
/// Reuse Codex CLI authentication and delegated refresh.
Codex,
/// GitHub Copilot Chat backend.
Copilot,
/// OpenCode cloud API (Go by default; AI_MEMORY_LLM_BASE_URL selects Zen).
@@ -69,6 +69,7 @@ impl From<LlmProviderChoice> for ProviderChoice {
LlmProviderChoice::Gemini => Self::Gemini,
LlmProviderChoice::OpenaiCompat => Self::OpenAiCompat,
LlmProviderChoice::OpenaiOauth => Self::OpenAiOAuth,
LlmProviderChoice::Codex => Self::Codex,
LlmProviderChoice::Copilot => Self::Copilot,
LlmProviderChoice::Opencode => Self::OpenCode,
}
+70 -4
View File
@@ -175,7 +175,8 @@ pub struct Config {
pub home_dir: Option<String>,
/// Per-subsystem log filter (overridable by `RUST_LOG`).
pub log_level: String,
/// Optional LLM provider (`anthropic`, `openai`, `gemini`, `openai-compat`, `openai-oauth`, `copilot`).
/// Optional LLM provider (`anthropic`, `openai`, `gemini`, `openai-compat`,
/// `openai-oauth`, `codex`, `copilot`).
pub llm_provider: Option<String>,
/// Optional LLM model override.
pub llm_model: Option<String>,
@@ -397,6 +398,9 @@ pub struct Config {
pub struct RuntimeEnv {
data_dir: Option<PathBuf>,
home_dir: Option<String>,
platform_home: Option<PathBuf>,
codex_home: Option<PathBuf>,
codex_executable: Option<PathBuf>,
server_url: Option<String>,
auth_token: Option<String>,
host_cwd: Option<String>,
@@ -424,6 +428,9 @@ impl RuntimeEnv {
Self {
data_dir: env_path("AI_MEMORY_DATA_DIR"),
home_dir: env_string("AI_MEMORY_HOME").or_else(|| env_string("HOME")),
platform_home: dirs::home_dir(),
codex_home: env_path("CODEX_HOME"),
codex_executable: env_path("AI_MEMORY_CODEX_EXECUTABLE"),
server_url: env_string("AI_MEMORY_SERVER_URL"),
auth_token: env_string("AI_MEMORY_AUTH_TOKEN"),
host_cwd: env_string("AI_MEMORY_HOST_CWD"),
@@ -1173,7 +1180,7 @@ impl Config {
let provider = provider_choice_from_str(provider_raw).ok_or_else(|| {
LlmError::NotConfigured(format!(
"AI_MEMORY_LLM_PROVIDER={provider_raw} is not one of \
anthropic|openai|gemini|openai-compat|openai-oauth|copilot|anthropic-oauth|opencode"
anthropic|openai|gemini|openai-compat|openai-oauth|codex|copilot|anthropic-oauth|opencode"
))
})?;
let model = match non_empty(self.llm_model.as_deref()) {
@@ -1184,6 +1191,7 @@ impl Config {
ProviderChoice::OpenAi => "gpt-5.4-mini".to_string(),
ProviderChoice::Gemini => "gemini-3.5-flash".to_string(),
ProviderChoice::OpenAiOAuth => "gpt-5.5".to_string(),
ProviderChoice::Codex => "gpt-5.6-luna".to_string(),
ProviderChoice::Copilot => "gpt-5.5".to_string(),
ProviderChoice::OpenAiCompat => {
return Err(LlmError::NotConfigured(
@@ -1232,7 +1240,7 @@ impl Config {
let provider = provider_choice_from_str(provider_raw).ok_or_else(|| {
LlmError::NotConfigured(format!(
"llm_fallbacks[{index}].provider={provider_raw} is not one of \
anthropic|openai|gemini|openai-compat|openai-oauth|copilot|anthropic-oauth|opencode"
anthropic|openai|gemini|openai-compat|openai-oauth|codex|copilot|anthropic-oauth|opencode"
))
})?;
let model = non_empty(Some(profile.model.as_str()))
@@ -1287,6 +1295,16 @@ impl Config {
AuthRequirement::OpenAiOAuthToken => {
ProviderAuth::openai_oauth_token_file(self.openai_oauth_token_path())
}
AuthRequirement::CodexAuthFile => ProviderAuth::codex(
resolve_codex_auth_file(
self.runtime_env.codex_home.as_deref(),
self.runtime_env.platform_home.as_deref(),
),
self.runtime_env
.codex_executable
.clone()
.unwrap_or_else(|| PathBuf::from("codex")),
),
AuthRequirement::CopilotToken => ProviderAuth::copilot(
self.copilot_token_path(),
self.runtime_env.copilot_github_token.clone(),
@@ -1491,6 +1509,7 @@ impl Config {
ProviderChoice::Gemini => self.runtime_env.gemini_api_key.clone(),
ProviderChoice::OpenAiCompat => self.runtime_env.llm_api_key.clone(),
ProviderChoice::OpenAiOAuth => None,
ProviderChoice::Codex => None,
ProviderChoice::Copilot => None,
ProviderChoice::AnthropicOAuth => None,
ProviderChoice::OpenCode => self.runtime_env.opencode_api_key.clone(),
@@ -1509,6 +1528,17 @@ impl Config {
self.auth_token_path()
}
/// Codex CLI-owned auth file resolved from the Codex or platform home.
#[must_use]
pub fn codex_auth_file_path(&self) -> PathBuf {
let platform_home = self
.runtime_env
.platform_home
.as_deref()
.or_else(|| self.runtime_env.home_dir.as_deref().map(Path::new));
resolve_codex_auth_file(self.runtime_env.codex_home.as_deref(), platform_home)
}
/// Shared Copilot auth token file path.
#[must_use]
pub fn copilot_token_path(&self) -> PathBuf {
@@ -1555,6 +1585,13 @@ impl Config {
AuthRequirement::OpenAiOAuthToken => {
ProviderAuth::openai_oauth_token_file(self.openai_oauth_token_path())
}
AuthRequirement::CodexAuthFile => ProviderAuth::codex(
self.codex_auth_file_path(),
self.runtime_env
.codex_executable
.clone()
.unwrap_or_else(|| PathBuf::from("codex")),
),
AuthRequirement::CopilotToken => ProviderAuth::copilot(
self.copilot_token_path(),
self.runtime_env.copilot_github_token.clone(),
@@ -1619,6 +1656,7 @@ fn provider_choice_from_str(raw: &str) -> Option<ProviderChoice> {
"gemini" | "google" => ProviderChoice::Gemini,
"openai-compat" | "openai_compat" => ProviderChoice::OpenAiCompat,
"openai-oauth" | "openai_oauth" => ProviderChoice::OpenAiOAuth,
"codex" => ProviderChoice::Codex,
"copilot" | "github-copilot" | "github_copilot" => ProviderChoice::Copilot,
"anthropic-oauth" | "anthropic_oauth" => ProviderChoice::AnthropicOAuth,
"opencode" | "opencode-zen" | "opencode_zen" => ProviderChoice::OpenCode,
@@ -1641,6 +1679,16 @@ fn env_path(name: &str) -> Option<PathBuf> {
env_string(name).map(PathBuf::from)
}
fn resolve_codex_auth_file(codex_home: Option<&Path>, platform_home: Option<&Path>) -> PathBuf {
if let Some(home) = codex_home.filter(|path| !path.as_os_str().is_empty()) {
return home.join("auth.json");
}
platform_home
.unwrap_or_else(|| Path::new("."))
.join(".codex")
.join("auth.json")
}
fn env_secret(name: &str) -> Option<SecretString> {
env_string(name).map(SecretString::from)
}
@@ -2838,7 +2886,7 @@ mod tests {
let cfg = Config {
llm_provider: Some("codex".into()),
runtime_env: RuntimeEnv {
home_dir: Some(tmp.path().display().to_string()),
platform_home: Some(tmp.path().to_path_buf()),
..RuntimeEnv::default()
},
..Config::default()
@@ -2868,6 +2916,24 @@ mod tests {
);
}
#[test]
fn codex_auth_resolution_treats_empty_codex_home_as_unset() {
let platform_home = Path::new("/platform/home");
assert_eq!(
resolve_codex_auth_file(None, Some(platform_home)),
platform_home.join(".codex").join("auth.json")
);
assert_eq!(
resolve_codex_auth_file(Some(Path::new("")), Some(platform_home)),
platform_home.join(".codex").join("auth.json")
);
assert_eq!(
resolve_codex_auth_file(Some(Path::new("/custom/codex")), Some(platform_home)),
Path::new("/custom/codex").join("auth.json")
);
}
#[test]
fn provider_config_defaults_to_no_operator_headers() {
let tmp = TempDir::new().unwrap();
+1 -1
View File
@@ -6,7 +6,7 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "LLM provider trait with typed Anthropic, OpenAI, Gemini, OpenAI OAuth, GitHub Copilot and OpenAI-compat clients."
description = "LLM provider trait with typed Anthropic, OpenAI, Gemini, Codex, OpenAI OAuth, GitHub Copilot and OpenAI-compat clients."
# One integration-test binary instead of one per file. Each binary
# statically links the whole dep graph and gets scanned by macOS on
# first run.
+52 -4
View File
@@ -42,6 +42,8 @@ pub enum AuthRequirement {
},
/// Provider requires a ChatGPT/Codex OAuth token file.
OpenAiOAuthToken,
/// Provider reuses the Codex CLI auth file and delegates refresh to Codex.
CodexAuthFile,
/// Provider requires a GitHub token or stored auth for Copilot.
CopilotToken,
/// Provider requires an Anthropic OAuth subscription token
@@ -62,6 +64,15 @@ pub struct CopilotAuth {
pub api_base_url: Option<String>,
}
/// Resolved, non-secret inputs for the Codex-backed provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexAuth {
/// Codex CLI-owned authentication file.
pub auth_file: PathBuf,
/// Codex executable used only for delegated refresh.
pub executable: PathBuf,
}
/// Materialized provider credential.
#[derive(Debug, Clone)]
pub enum Credential {
@@ -69,6 +80,8 @@ pub enum Credential {
ApiKey(SecretString),
/// Path to the OpenAI OAuth token file.
OpenAiOAuthTokenFile(PathBuf),
/// Paths needed to reuse Codex CLI authentication.
Codex(CodexAuth),
/// GitHub Copilot auth inputs.
Copilot(CopilotAuth),
/// Anthropic OAuth subscription token from `claude setup-token`.
@@ -114,6 +127,19 @@ impl ProviderAuth {
}
}
/// Resolve Codex auth from already-resolved auth-file and executable paths.
#[must_use]
pub fn codex(auth_file: impl Into<PathBuf>, executable: impl Into<PathBuf>) -> Self {
Self {
requirement: AuthRequirement::CodexAuthFile,
credential: Some(Credential::Codex(CodexAuth {
auth_file: auth_file.into(),
executable: executable.into(),
})),
source: CredentialSource::TokenFile,
}
}
/// Resolve Copilot auth from a shared token file plus optional env tokens.
#[must_use]
pub fn copilot(
@@ -201,6 +227,9 @@ impl ProviderAuth {
(_, Some(Credential::OpenAiOAuthTokenFile(_))) => Err(LlmError::NotConfigured(
"API key credential expected, got openai-oauth token file".into(),
)),
(_, Some(Credential::Codex(_))) => Err(LlmError::NotConfigured(
"API key credential expected, got codex auth file".into(),
)),
(_, Some(Credential::Copilot(_))) => Err(LlmError::NotConfigured(
"API key credential expected, got copilot auth".into(),
)),
@@ -216,6 +245,10 @@ impl ProviderAuth {
(AuthRequirement::OpenAiOAuthToken, None) => Err(LlmError::NotConfigured(
"openai-oauth token file missing; run `ai-memory auth login openai-oauth`".into(),
)),
(AuthRequirement::CodexAuthFile, None) => Err(LlmError::NotConfigured(
"codex auth file missing; run `codex login status` and authenticate Codex again"
.into(),
)),
(AuthRequirement::CopilotToken, None) => Err(LlmError::NotConfigured(
"copilot auth missing; run `ai-memory auth login copilot` or set COPILOT_GITHUB_TOKEN"
.into(),
@@ -235,6 +268,7 @@ impl ProviderAuth {
Some(Credential::ApiKey(key)) => Some(key.clone()),
Some(
Credential::OpenAiOAuthTokenFile(_)
| Credential::Codex(_)
| Credential::Copilot(_)
| Credential::AnthropicOAuthToken(_),
)
@@ -282,6 +316,23 @@ impl ProviderAuth {
}
}
/// Extract the resolved Codex auth inputs.
///
/// # Errors
/// Returns [`LlmError::NotConfigured`] if this is not Codex auth.
pub fn require_codex_auth(&self) -> LlmResult<CodexAuth> {
match (&self.requirement, &self.credential) {
(AuthRequirement::CodexAuthFile, Some(Credential::Codex(auth))) => Ok(auth.clone()),
(AuthRequirement::CodexAuthFile, None) => Err(LlmError::NotConfigured(
"codex auth file missing; run `codex login status` and authenticate Codex again"
.into(),
)),
_ => Err(LlmError::NotConfigured(
"codex auth-file credential required".into(),
)),
}
}
/// Extract Copilot auth inputs.
///
/// # Errors
@@ -423,10 +474,7 @@ mod tests {
#[test]
fn codex_auth_round_trips_only_resolved_paths() {
let auth = ProviderAuth::codex(
"/tmp/.codex/auth.json",
"/opt/codex/bin/codex",
);
let auth = ProviderAuth::codex("/tmp/.codex/auth.json", "/opt/codex/bin/codex");
let codex = auth.require_codex_auth().unwrap();
assert_eq!(auth.requirement(), AuthRequirement::CodexAuthFile);
+820
View File
@@ -0,0 +1,820 @@
//! Codex provider that reuses the Codex CLI-owned authentication file.
use std::fmt;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use async_trait::async_trait;
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
use serde_json::json;
use tokio::io::{
AsyncBufRead, AsyncBufReadExt as _, AsyncRead, AsyncReadExt as _, AsyncWriteExt as _,
};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use crate::auth::CodexAuth;
use crate::codex_responses::{CODEX_RESPONSES_URL, CodexResponsesAuth, post_codex_responses};
use crate::error::{LlmError, LlmResult};
use crate::openai::{STRUCTURED_OUTPUT_SCHEMA_NAME, enforce_strict_object_schemas};
use crate::openai_oauth::{
CodexResponsesRequest, CodexText, CodexTextFormat, build_request, extract_output_text,
into_chat_response,
};
use crate::provider::LlmProvider;
use crate::types::{ChatRequest, ChatResponse, ExtraHeaders, ReasoningEffort};
const MAX_JSONL_LINE_BYTES: usize = 256 * 1024;
const MAX_STDOUT_BYTES: usize = 1024 * 1024;
const MAX_STDERR_BYTES: usize = 64 * 1024;
const MAX_RECOVERY_SECS: u64 = 30;
#[derive(Deserialize)]
struct CodexAuthFile {
tokens: CodexTokenFields,
}
#[derive(Deserialize)]
struct CodexTokenFields {
access_token: String,
account_id: String,
}
#[derive(Clone)]
struct CodexCredentials {
access_token: SecretString,
account_id: String,
}
impl fmt::Debug for CodexCredentials {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CodexCredentials([REDACTED])")
}
}
/// ChatGPT/Codex Responses provider backed by Codex CLI credentials.
pub struct CodexProvider {
client: reqwest::Client,
model: String,
auth: CodexAuth,
timeout: Duration,
reasoning_effort: Option<ReasoningEffort>,
extra_headers: ExtraHeaders,
recovery: Mutex<()>,
responses_url: String,
}
impl CodexProvider {
/// Construct the provider and validate that Codex's auth file is usable.
///
/// # Errors
/// Returns a sanitized authentication error for missing or malformed auth.
pub fn new(auth: CodexAuth, model: impl Into<String>) -> LlmResult<Self> {
read_credentials(&auth.auth_file)?;
Ok(Self {
client: reqwest::Client::builder().build().map_err(LlmError::from)?,
model: model.into(),
auth,
timeout: Duration::from_secs(crate::DEFAULT_REQUEST_TIMEOUT_SECS),
reasoning_effort: None,
extra_headers: ExtraHeaders::default(),
recovery: Mutex::new(()),
responses_url: CODEX_RESPONSES_URL.into(),
})
}
/// Override the Responses request and recovery ceiling.
#[must_use]
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout = Duration::from_secs(secs);
self
}
/// Set Responses API `reasoning.effort`.
#[must_use]
pub fn with_reasoning_effort(mut self, effort: Option<ReasoningEffort>) -> Self {
self.reasoning_effort = effort;
self
}
/// Attach operator-configured headers to every Responses request.
#[must_use]
pub fn with_extra_headers(mut self, headers: ExtraHeaders) -> Self {
self.extra_headers = headers;
self
}
#[cfg(test)]
fn with_responses_url(mut self, url: impl Into<String>) -> Self {
self.responses_url = url.into();
self
}
async fn post_once(
&self,
credentials: &CodexCredentials,
body: &CodexResponsesRequest<'_>,
) -> LlmResult<crate::openai_oauth::CodexResponsesResponse> {
post_codex_responses(
&self.client,
&self.responses_url,
self.timeout,
&CodexResponsesAuth {
access_token: &credentials.access_token,
account_id: Some(&credentials.account_id),
},
&self.extra_headers,
body,
)
.await
}
async fn post(
&self,
body: &CodexResponsesRequest<'_>,
) -> LlmResult<crate::openai_oauth::CodexResponsesResponse> {
let initial = read_credentials(&self.auth.auth_file)?;
match self.post_once(&initial, body).await {
Err(LlmError::Provider { status: 401, .. }) => {}
result => return result,
}
let mut current = read_credentials(&self.auth.auth_file)?;
if same_access_token(&initial, &current) {
let _guard = self.recovery.lock().await;
current = read_credentials(&self.auth.auth_file)?;
if same_access_token(&initial, &current) {
recover_with_codex(&self.auth, self.timeout).await?;
current = read_credentials(&self.auth.auth_file)?;
if same_access_token(&initial, &current) {
return Err(codex_reauth_error(
"Codex completed recovery without replacing the access token",
));
}
}
}
match self.post_once(&current, body).await {
Err(LlmError::Provider { status: 401, .. }) => Err(codex_reauth_error(
"Codex authentication was rejected after one recovery attempt",
)),
result => result,
}
}
}
#[async_trait]
impl LlmProvider for CodexProvider {
fn name(&self) -> &'static str {
"codex"
}
fn model(&self) -> &str {
&self.model
}
async fn complete(&self, request: ChatRequest) -> LlmResult<ChatResponse> {
let response = self
.post(&build_request(
&self.model,
&request,
None,
self.reasoning_effort,
))
.await?;
Ok(into_chat_response(response))
}
async fn complete_structured_raw(
&self,
request: ChatRequest,
mut schema: serde_json::Value,
) -> LlmResult<serde_json::Value> {
enforce_strict_object_schemas(&mut schema);
let response = self
.post(&build_request(
&self.model,
&request,
Some(CodexText {
format: CodexTextFormat::JsonSchema {
name: STRUCTURED_OUTPUT_SCHEMA_NAME.into(),
schema,
strict: true,
},
}),
self.reasoning_effort,
))
.await?;
serde_json::from_str(&extract_output_text(&response).unwrap_or_default())
.map_err(LlmError::from)
}
}
fn read_credentials(path: &Path) -> LlmResult<CodexCredentials> {
let bytes = std::fs::read(path).map_err(|_| {
codex_reauth_error(&format!(
"Codex auth file is unavailable at {} (file storage is required; keyring-only and ephemeral storage are unsupported)",
path.display()
))
})?;
let file: CodexAuthFile = serde_json::from_slice(&bytes).map_err(|_| {
codex_reauth_error(&format!("Codex auth file is invalid at {}", path.display()))
})?;
let access_token = file.tokens.access_token.trim();
let account_id = file.tokens.account_id.trim();
if access_token.is_empty() || account_id.is_empty() {
return Err(codex_reauth_error(
"Codex auth file requires non-empty tokens.access_token and tokens.account_id",
));
}
Ok(CodexCredentials {
access_token: SecretString::from(access_token.to_owned()),
account_id: account_id.to_owned(),
})
}
fn same_access_token(left: &CodexCredentials, right: &CodexCredentials) -> bool {
left.access_token.expose_secret() == right.access_token.expose_secret()
}
fn codex_reauth_error(reason: &str) -> LlmError {
LlmError::Auth(format!(
"{reason}; run `codex login status` and authenticate Codex again if needed"
))
}
async fn recover_with_codex(auth: &CodexAuth, request_timeout: Duration) -> LlmResult<()> {
let mut command = Command::new(&auth.executable);
command
.args(["app-server", "--stdio"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(codex_home) = auth.auth_file.parent() {
command.env("CODEX_HOME", codex_home);
}
let mut child = command.spawn().map_err(|_| {
codex_reauth_error("Could not start `codex app-server --stdio` for recovery")
})?;
let timeout = request_timeout.min(Duration::from_secs(MAX_RECOVERY_SECS));
let result = tokio::time::timeout(timeout, run_recovery_protocol(&mut child)).await;
terminate_child(&mut child).await;
match result {
Ok(result) => result,
Err(_) => Err(codex_reauth_error(
"Codex authentication recovery timed out",
)),
}
}
async fn run_recovery_protocol(child: &mut Child) -> LlmResult<()> {
let mut stdin = child
.stdin
.take()
.ok_or_else(|| codex_reauth_error("Codex recovery process did not expose stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| codex_reauth_error("Codex recovery process did not expose stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| codex_reauth_error("Codex recovery process did not expose stderr"))?;
let mut stdout = tokio::io::BufReader::new(stdout);
let stderr_task = tokio::spawn(monitor_stderr(stderr));
let protocol = async {
let mut stdout_bytes = 0_usize;
write_json_line(
&mut stdin,
&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"clientInfo": {"name": "ai-memory", "version": env!("CARGO_PKG_VERSION")},
"capabilities": {}
}
}),
)
.await?;
read_expected_response(&mut stdout, 1, &mut stdout_bytes).await?;
write_json_line(
&mut stdin,
&json!({"jsonrpc": "2.0", "method": "initialized", "params": {}}),
)
.await?;
write_json_line(
&mut stdin,
&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "account/read",
"params": {"refreshToken": true}
}),
)
.await?;
read_expected_response(&mut stdout, 2, &mut stdout_bytes).await
};
tokio::pin!(protocol);
tokio::pin!(stderr_task);
tokio::select! {
result = &mut protocol => {
stderr_task.abort();
result
}
stderr_result = &mut stderr_task => match stderr_result {
Ok(result) => result,
Err(_) => Err(codex_reauth_error("Codex recovery stderr monitor failed")),
},
}
}
async fn write_json_line(
writer: &mut (impl tokio::io::AsyncWrite + Unpin),
value: &serde_json::Value,
) -> LlmResult<()> {
let mut line = serde_json::to_vec(value).map_err(LlmError::from)?;
line.push(b'\n');
writer
.write_all(&line)
.await
.map_err(|_| codex_reauth_error("Could not write to Codex recovery process"))?;
writer
.flush()
.await
.map_err(|_| codex_reauth_error("Could not flush Codex recovery request"))
}
async fn read_expected_response(
reader: &mut (impl AsyncBufRead + Unpin),
expected_id: u64,
total: &mut usize,
) -> LlmResult<()> {
loop {
let line = read_limited_line(reader, total).await?;
let value: serde_json::Value = serde_json::from_slice(&line)
.map_err(|_| codex_reauth_error("Codex recovery returned invalid JSON"))?;
let Some(id) = value.get("id") else {
continue;
};
if id.as_u64() != Some(expected_id) {
return Err(codex_reauth_error(
"Codex recovery returned an unexpected JSON-RPC response id",
));
}
if value.get("error").is_some() {
return Err(codex_reauth_error(
"Codex recovery returned a JSON-RPC error",
));
}
if value.get("result").is_none() {
return Err(codex_reauth_error(
"Codex recovery response did not contain a result",
));
}
return Ok(());
}
}
async fn read_limited_line(
reader: &mut (impl AsyncBufRead + Unpin),
total: &mut usize,
) -> LlmResult<Vec<u8>> {
let mut line = Vec::new();
loop {
let available = reader
.fill_buf()
.await
.map_err(|_| codex_reauth_error("Could not read Codex recovery output"))?;
if available.is_empty() {
return Err(codex_reauth_error(
"Codex recovery process ended before replying",
));
}
let take = available
.iter()
.position(|byte| *byte == b'\n')
.map_or(available.len(), |index| index + 1);
if line.len().saturating_add(take) > MAX_JSONL_LINE_BYTES
|| total.saturating_add(take) > MAX_STDOUT_BYTES
{
return Err(codex_reauth_error(
"Codex recovery output exceeded its limit",
));
}
line.extend_from_slice(&available[..take]);
reader.consume(take);
*total += take;
if line.last() == Some(&b'\n') {
return Ok(line);
}
}
}
async fn monitor_stderr(mut stderr: impl AsyncRead + Unpin) -> LlmResult<()> {
let mut total = 0_usize;
let mut buffer = [0_u8; 4096];
loop {
let read = stderr
.read(&mut buffer)
.await
.map_err(|_| codex_reauth_error("Could not read Codex recovery stderr"))?;
if read == 0 {
return Err(codex_reauth_error(
"Codex recovery process ended before completing",
));
}
total = total.saturating_add(read);
if total > MAX_STDERR_BYTES {
return Err(codex_reauth_error(
"Codex recovery stderr exceeded its limit",
));
}
}
}
async fn terminate_child(child: &mut Child) {
if child.try_wait().ok().flatten().is_none() {
let _ = child.kill().await;
}
let _ = child.wait().await;
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::process::Command as StdCommand;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use secrecy::ExposeSecret as _;
use serde_json::json;
use wiremock::matchers::{method, path as request_path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use super::*;
fn write_auth(path: &Path, access: &str, account: &str) {
fs::write(
path,
serde_json::to_vec_pretty(&json!({
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "must-not-be-materialized",
"access_token": access,
"refresh_token": "must-not-be-materialized",
"account_id": account,
"future": {"accepted": true}
}
}))
.unwrap(),
)
.unwrap();
}
fn compile_fake_codex(dir: &Path) -> PathBuf {
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("support")
.join("fake_codex.rs");
let executable = dir.join(if cfg!(windows) {
"fake-codex.exe"
} else {
"fake-codex"
});
let status = StdCommand::new("rustc")
.arg(source)
.arg("-o")
.arg(&executable)
.status()
.unwrap();
assert!(status.success());
executable
}
fn completed_sse(text: &str) -> String {
format!(
"event: response.completed\ndata: {}\n\n",
json!({
"type": "response.completed",
"response": {
"output_text": text,
"model": "gpt-5.6-luna",
"usage": {"input_tokens": 3, "output_tokens": 2}
}
})
)
}
#[derive(Clone)]
struct RotateThenRespond {
auth_path: PathBuf,
calls: Arc<AtomicUsize>,
second_status: u16,
}
impl Respond for RotateThenRespond {
fn respond(&self, request: &Request) -> ResponseTemplate {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
let authorization = request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok());
let account = request
.headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok());
if account != Some("account-secret") {
return ResponseTemplate::new(500).set_body_string("missing account header");
}
let body: serde_json::Value = match serde_json::from_slice(&request.body) {
Ok(body) => body,
Err(_) => return ResponseTemplate::new(500).set_body_string("invalid body"),
};
if body["model"] != "gpt-5.6-luna" || body["stream"] != true {
return ResponseTemplate::new(500).set_body_string("wrong model or stream mode");
}
if body.get("reasoning").is_some() && body["reasoning"]["effort"] != json!("medium") {
return ResponseTemplate::new(500).set_body_string("wrong reasoning effort");
}
for (name, expected) in [
("accept", "text/event-stream"),
("openai-beta", "responses=experimental"),
("originator", "codex_cli_rs"),
] {
if request
.headers
.get(name)
.and_then(|value| value.to_str().ok())
!= Some(expected)
{
return ResponseTemplate::new(500)
.set_body_string(format!("wrong {name} header"));
}
}
if call == 0 {
if authorization != Some("Bearer old-token") {
return ResponseTemplate::new(500).set_body_string("wrong initial token");
}
write_auth(&self.auth_path, "new-token", "account-secret");
return ResponseTemplate::new(401).set_body_string("expired");
}
if authorization != Some("Bearer new-token") {
return ResponseTemplate::new(500).set_body_string("wrong reloaded token");
}
if self.second_status == 200 {
ResponseTemplate::new(200).set_body_string(completed_sse("ok"))
} else {
ResponseTemplate::new(self.second_status).set_body_string("still rejected")
}
}
}
#[test]
fn auth_parser_reads_only_required_non_empty_fields_without_mutation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
write_auth(&path, "access-secret", "account-secret");
let before = fs::read(&path).unwrap();
let credentials = read_credentials(&path).unwrap();
assert_eq!(credentials.access_token.expose_secret(), "access-secret");
assert_eq!(credentials.account_id, "account-secret");
assert_eq!(fs::read(&path).unwrap(), before);
let debug = format!("{credentials:?}");
assert!(!debug.contains("access-secret"));
assert!(!debug.contains("account-secret"));
assert!(!debug.contains("must-not-be-materialized"));
}
#[test]
fn auth_parser_rejects_missing_invalid_and_empty_credentials_safely() {
let dir = tempfile::tempdir().unwrap();
for (name, bytes) in [
("missing.json", br#"{}"#.as_slice()),
("invalid.json", br#"{not-json"#.as_slice()),
(
"empty.json",
br#"{"tokens":{"access_token":" ","account_id":"acct"}}"#.as_slice(),
),
] {
let path = dir.path().join(name);
fs::write(&path, bytes).unwrap();
let error = read_credentials(&path).unwrap_err().to_string();
assert!(error.contains("codex login status"));
assert!(!error.contains("access_token\":\""));
}
}
#[tokio::test]
async fn protocol_ignores_notifications_and_rejects_unexpected_ids() {
let (writer, reader) = tokio::io::duplex(4096);
let mut reader = tokio::io::BufReader::new(reader);
tokio::spawn(async move {
let mut writer = writer;
writer
.write_all(
b"{\"jsonrpc\":\"2.0\",\"method\":\"notice\"}\n{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}\n",
)
.await
.unwrap();
});
let error = read_expected_response(&mut reader, 1, &mut 0)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("unexpected JSON-RPC response id")
);
}
#[tokio::test]
async fn protocol_enforces_per_line_and_total_limits() {
let oversized = vec![b'x'; MAX_JSONL_LINE_BYTES + 1];
let mut reader = tokio::io::BufReader::new(oversized.as_slice());
let error = read_limited_line(&mut reader, &mut 0).await.unwrap_err();
assert!(error.to_string().contains("exceeded its limit"));
let mut reader = tokio::io::BufReader::new(b"{}\n".as_slice());
let mut total = MAX_STDOUT_BYTES;
let error = read_limited_line(&mut reader, &mut total)
.await
.unwrap_err();
assert!(error.to_string().contains("exceeded its limit"));
}
#[tokio::test]
async fn provider_reloads_rotated_token_and_retries_only_once() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
write_auth(&auth_path, "old-token", "account-secret");
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(request_path("/responses"))
.respond_with(RotateThenRespond {
auth_path: auth_path.clone(),
calls: calls.clone(),
second_status: 200,
})
.mount(&server)
.await;
let provider = CodexProvider::new(
CodexAuth {
auth_file: auth_path.clone(),
executable: PathBuf::from("must-not-run"),
},
"gpt-5.6-luna",
)
.unwrap()
.with_responses_url(format!("{}/responses", server.uri()))
.with_reasoning_effort(Some(ReasoningEffort::Medium));
let response = provider
.complete(ChatRequest::user_prompt("test"))
.await
.unwrap();
assert_eq!(response.text, "ok");
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn provider_does_not_loop_after_second_unauthorized_response() {
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
write_auth(&auth_path, "old-token", "account-secret");
let calls = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(request_path("/responses"))
.respond_with(RotateThenRespond {
auth_path: auth_path.clone(),
calls: calls.clone(),
second_status: 401,
})
.mount(&server)
.await;
let provider = CodexProvider::new(
CodexAuth {
auth_file: auth_path,
executable: PathBuf::from("must-not-run"),
},
"gpt-5.6-luna",
)
.unwrap()
.with_responses_url(format!("{}/responses", server.uri()));
let error = provider
.complete(ChatRequest::user_prompt("test"))
.await
.unwrap_err();
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(error.to_string().contains("codex login status"));
assert!(!error.to_string().contains("old-token"));
assert!(!error.to_string().contains("new-token"));
}
#[tokio::test]
async fn provider_sends_and_parses_structured_responses() {
#[derive(Clone)]
struct StructuredResponder;
impl Respond for StructuredResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
if body["text"]["format"]["type"] != "json_schema"
|| body["text"]["format"]["strict"] != true
{
return ResponseTemplate::new(500).set_body_string("missing JSON schema");
}
ResponseTemplate::new(200).set_body_string(completed_sse("{\"answer\":42}"))
}
}
let server = MockServer::start().await;
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
write_auth(&auth_path, "access-secret", "account-secret");
Mock::given(method("POST"))
.and(request_path("/responses"))
.respond_with(StructuredResponder)
.expect(1)
.mount(&server)
.await;
let provider = CodexProvider::new(
CodexAuth {
auth_file: auth_path,
executable: PathBuf::from("must-not-run"),
},
"gpt-5.6-luna",
)
.unwrap()
.with_responses_url(format!("{}/responses", server.uri()));
let value = provider
.complete_structured_raw(
ChatRequest::user_prompt("return JSON"),
json!({
"type": "object",
"properties": {"answer": {"type": "integer"}}
}),
)
.await
.unwrap();
assert_eq!(value, json!({"answer": 42}));
}
#[tokio::test]
async fn rust_fake_codex_exercises_recovery_and_defensive_failures() {
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let executable = compile_fake_codex(dir.path());
let auth = CodexAuth {
auth_file: auth_path.clone(),
executable,
};
write_auth(&auth_path, "old-token", "account-secret");
fs::write(dir.path().join("fake-mode"), "success").unwrap();
recover_with_codex(&auth, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(
read_credentials(&auth_path)
.unwrap()
.access_token
.expose_secret(),
"new-token"
);
for (mode, expected) in [
("wrong-id", "unexpected JSON-RPC response id"),
("invalid", "invalid JSON"),
("oversized", "exceeded its limit"),
("stderr", "stderr exceeded its limit"),
("exit", "ended before replying"),
] {
fs::write(dir.path().join("fake-mode"), mode).unwrap();
let error = recover_with_codex(&auth, Duration::from_secs(3))
.await
.unwrap_err()
.to_string();
assert!(error.contains(expected), "mode={mode}, error={error}");
}
fs::write(dir.path().join("fake-mode"), "sleep").unwrap();
let error = recover_with_codex(&auth, Duration::from_secs(1))
.await
.unwrap_err()
.to_string();
assert!(error.contains("timed out"));
}
}
@@ -0,0 +1,64 @@
//! Shared transport for the ChatGPT/Codex Responses backend.
use std::time::Duration;
use secrecy::{ExposeSecret as _, SecretString};
use tracing::debug;
use crate::error::{LlmError, LlmResult};
use crate::openai_oauth::{CodexResponsesRequest, CodexResponsesResponse, parse_sse_response};
use crate::response::{provider_error_body, response_json_limited, response_text_limited};
use crate::types::ExtraHeaders;
/// ChatGPT/Codex Responses backend.
pub const CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
pub(crate) struct CodexResponsesAuth<'a> {
pub(crate) access_token: &'a SecretString,
pub(crate) account_id: Option<&'a str>,
}
pub(crate) async fn post_codex_responses(
client: &reqwest::Client,
url: &str,
timeout: Duration,
auth: &CodexResponsesAuth<'_>,
extra_headers: &ExtraHeaders,
body: &CodexResponsesRequest<'_>,
) -> LlmResult<CodexResponsesResponse> {
debug!(url, "POST codex responses");
let mut request = client
.post(url)
.timeout(timeout)
.bearer_auth(auth.access_token.expose_secret())
.header("content-type", "application/json")
.header(
"accept",
if body.stream {
"text/event-stream"
} else {
"application/json"
},
)
.header("openai-beta", "responses=experimental")
.header("originator", "codex_cli_rs")
.header("session_id", uuid::Uuid::new_v4().to_string())
.json(body);
request = extra_headers.apply(request);
if let Some(account_id) = auth.account_id {
request = request.header("chatgpt-account-id", account_id);
}
let response = request.send().await.map_err(LlmError::from)?;
let status = response.status();
if !status.is_success() {
return Err(LlmError::Provider {
status: status.as_u16(),
body: provider_error_body(response).await,
});
}
if body.stream {
parse_sse_response(&response_text_limited(response).await?)
} else {
response_json_limited::<CodexResponsesResponse>(response).await
}
}
+14
View File
@@ -8,6 +8,7 @@ use std::sync::Arc;
use secrecy::{ExposeSecret, SecretString};
use crate::AnthropicProvider;
use crate::CodexProvider;
use crate::CopilotProvider;
use crate::GeminiProvider;
use crate::OpenAiCompatProvider;
@@ -33,6 +34,8 @@ pub enum ProviderChoice {
OpenAiCompat,
/// OpenAI ChatGPT/Codex OAuth backend.
OpenAiOAuth,
/// Codex CLI-owned auth with refresh delegated to `codex app-server`.
Codex,
/// GitHub Copilot Chat backend.
Copilot,
/// Anthropic Messages API via a Claude-subscription OAuth token.
@@ -52,6 +55,7 @@ impl ProviderChoice {
Self::Gemini => "gemini",
Self::OpenAiCompat => "openai-compat",
Self::OpenAiOAuth => "openai-oauth",
Self::Codex => "codex",
Self::Copilot => "copilot",
Self::AnthropicOAuth => "anthropic-oauth",
Self::OpenCode => "opencode",
@@ -75,6 +79,7 @@ impl ProviderChoice {
env_var: "LLM_API_KEY",
},
Self::OpenAiOAuth => AuthRequirement::OpenAiOAuthToken,
Self::Codex => AuthRequirement::CodexAuthFile,
Self::Copilot => AuthRequirement::CopilotToken,
Self::AnthropicOAuth => AuthRequirement::AnthropicOAuthToken,
Self::OpenCode => AuthRequirement::RequiredApiKey {
@@ -360,6 +365,15 @@ pub fn build_provider(config: ProviderConfig) -> LlmResult<Arc<dyn LlmProvider>>
.with_extra_headers(extra_headers),
))
}
ProviderChoice::Codex => {
let auth = config.auth.require_codex_auth()?;
Ok(Arc::new(
CodexProvider::new(auth, config.model)?
.with_timeout_secs(timeout)
.with_reasoning_effort(config.reasoning_effort)
.with_extra_headers(extra_headers),
))
}
ProviderChoice::Copilot => {
let auth = config.auth.require_copilot_auth()?;
Ok(Arc::new(
+6 -1
View File
@@ -56,6 +56,7 @@ pub const DEFAULT_USER_AGENT: &str = concat!("ai-memory/", env!("CARGO_PKG_VERSI
pub mod anthropic;
pub mod auth;
pub mod codex;
pub mod copilot;
pub mod embedding;
pub mod error;
@@ -76,12 +77,16 @@ pub mod reranker;
pub mod types;
mod auth_file;
mod codex_responses;
mod response;
mod stored_token;
mod text;
pub use anthropic::AnthropicProvider;
pub use auth::{AuthRequirement, CopilotAuth, Credential, CredentialSource, ProviderAuth};
pub use auth::{
AuthRequirement, CodexAuth, CopilotAuth, Credential, CredentialSource, ProviderAuth,
};
pub use codex::CodexProvider;
pub use copilot::{
COPILOT_INTEGRATION_ID, CopilotProvider, CopilotToken, DEFAULT_COPILOT_API_BASE_URL,
GITHUB_ACCESS_TOKEN_URL, GITHUB_COPILOT_CLIENT_ID, GITHUB_COPILOT_TOKEN_URL,
+25 -54
View File
@@ -13,13 +13,13 @@ use base64::Engine as _;
use secrecy::{ExposeSecret as _, SecretString};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing::{debug, info};
use tracing::info;
use crate::auth_file::now_ms;
use crate::codex_responses::{CodexResponsesAuth, post_codex_responses};
use crate::error::{LlmError, LlmResult};
use crate::openai::{STRUCTURED_OUTPUT_SCHEMA_NAME, enforce_strict_object_schemas};
use crate::provider::LlmProvider;
use crate::response::{provider_error_body, response_json_limited, response_text_limited};
use crate::stored_token::{StoredOAuthToken, refresh_grant};
use crate::text::truncate_with_ellipsis;
use crate::types::{ChatRequest, ChatResponse, ExtraHeaders, ReasoningEffort, Usage};
@@ -33,8 +33,7 @@ pub const OPENAI_OAUTH_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize
/// OpenAI OAuth token endpoint.
pub const OPENAI_OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
/// ChatGPT/Codex Responses backend.
pub const CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
pub use crate::codex_responses::CODEX_RESPONSES_URL;
/// Public Codex/OpenCode OAuth client id.
pub const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -204,46 +203,18 @@ impl OpenAiOAuthProvider {
async fn post(&self, body: &CodexResponsesRequest<'_>) -> LlmResult<CodexResponsesResponse> {
let token = self.current_token().await?;
debug!(
url = CODEX_RESPONSES_URL,
"POST openai-oauth codex responses"
);
let mut request = self
.client
.post(CODEX_RESPONSES_URL)
.timeout(self.timeout)
.bearer_auth(token.access.expose_secret())
.header("content-type", "application/json")
.header(
"accept",
if body.stream {
"text/event-stream"
} else {
"application/json"
},
)
.header("openai-beta", "responses=experimental")
.header("originator", "codex_cli_rs")
.header("session_id", uuid::Uuid::new_v4().to_string())
.json(body);
request = self.extra_headers.apply(request);
if let Some(account_id) = token.extra.account_id.as_deref() {
request = request.header("chatgpt-account-id", account_id);
}
let resp = request.send().await.map_err(LlmError::from)?;
let status = resp.status();
if !status.is_success() {
let body = provider_error_body(resp).await;
return Err(LlmError::Provider {
status: status.as_u16(),
body,
});
}
if body.stream {
parse_sse_response(&response_text_limited(resp).await?)
} else {
response_json_limited::<CodexResponsesResponse>(resp).await
}
post_codex_responses(
&self.client,
CODEX_RESPONSES_URL,
self.timeout,
&CodexResponsesAuth {
access_token: &token.access,
account_id: token.extra.account_id.as_deref(),
},
&self.extra_headers,
body,
)
.await
}
}
@@ -319,7 +290,7 @@ async fn refresh_access_token(
))
}
fn build_request<'a>(
pub(crate) fn build_request<'a>(
model: &'a str,
request: &'a ChatRequest,
text: Option<CodexText>,
@@ -357,7 +328,7 @@ fn build_request<'a>(
}
}
fn parse_sse_response(body: &str) -> LlmResult<CodexResponsesResponse> {
pub(crate) fn parse_sse_response(body: &str) -> LlmResult<CodexResponsesResponse> {
let mut current_event: Option<String> = None;
let mut data_lines: Vec<&str> = Vec::new();
let mut output_text = String::new();
@@ -463,7 +434,7 @@ fn model_uses_default_temperature(model: &str) -> bool {
}
#[derive(Debug, Serialize)]
struct CodexResponsesRequest<'a> {
pub(crate) struct CodexResponsesRequest<'a> {
model: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<&'a str>,
@@ -473,7 +444,7 @@ struct CodexResponsesRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
store: bool,
stream: bool,
pub(crate) stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<CodexText>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -499,13 +470,13 @@ struct CodexInputContent<'a> {
}
#[derive(Debug, Serialize)]
struct CodexText {
format: CodexTextFormat,
pub(crate) struct CodexText {
pub(crate) format: CodexTextFormat,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum CodexTextFormat {
pub(crate) enum CodexTextFormat {
JsonSchema {
name: String,
schema: serde_json::Value,
@@ -514,7 +485,7 @@ enum CodexTextFormat {
}
#[derive(Debug, Deserialize)]
struct CodexResponsesResponse {
pub(crate) struct CodexResponsesResponse {
#[serde(default)]
output_text: Option<String>,
#[serde(default)]
@@ -545,7 +516,7 @@ struct CodexUsage {
output_tokens: u32,
}
fn into_chat_response(response: CodexResponsesResponse) -> ChatResponse {
pub(crate) fn into_chat_response(response: CodexResponsesResponse) -> ChatResponse {
let model = response
.model
.clone()
@@ -560,7 +531,7 @@ fn into_chat_response(response: CodexResponsesResponse) -> ChatResponse {
}
}
fn extract_output_text(response: &CodexResponsesResponse) -> Option<String> {
pub(crate) fn extract_output_text(response: &CodexResponsesResponse) -> Option<String> {
if let Some(text) = response
.output_text
.as_deref()
@@ -0,0 +1,56 @@
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
fn main() {
let codex_home = PathBuf::from(std::env::var_os("CODEX_HOME").expect("CODEX_HOME"));
let mode = fs::read_to_string(codex_home.join("fake-mode"))
.unwrap_or_else(|_| "success".into());
if mode.trim() == "sleep" {
thread::sleep(Duration::from_secs(10));
return;
}
if mode.trim() == "exit" {
return;
}
if mode.trim() == "stderr" {
io::stderr().write_all(&vec![b'x'; 70 * 1024]).unwrap();
io::stderr().flush().unwrap();
thread::sleep(Duration::from_secs(10));
return;
}
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let mut stdout = io::stdout();
let initialize = lines.next().expect("initialize line").expect("initialize");
assert!(initialize.contains("\"method\":\"initialize\""));
match mode.trim() {
"invalid" => writeln!(stdout, "not-json").unwrap(),
"wrong-id" => writeln!(stdout, "{{\"jsonrpc\":\"2.0\",\"id\":99,\"result\":{{}}}}").unwrap(),
"oversized" => writeln!(stdout, "{}", "x".repeat(257 * 1024)).unwrap(),
_ => {
writeln!(stdout, "{{\"jsonrpc\":\"2.0\",\"method\":\"notice\"}}").unwrap();
writeln!(stdout, "{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{{}}}}").unwrap();
}
}
stdout.flush().unwrap();
if !matches!(mode.trim(), "success" | "no-change") {
thread::sleep(Duration::from_secs(10));
return;
}
let initialized = lines.next().expect("initialized line").expect("initialized");
assert!(initialized.contains("\"method\":\"initialized\""));
let account_read = lines.next().expect("account/read line").expect("account/read");
assert!(account_read.contains("\"method\":\"account/read\""));
assert!(account_read.contains("\"refreshToken\":true"));
if mode.trim() == "success" {
let auth_path = codex_home.join("auth.json");
let auth = fs::read_to_string(&auth_path).unwrap();
fs::write(auth_path, auth.replace("old-token", "new-token")).unwrap();
}
writeln!(stdout, "{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{}}}}").unwrap();
stdout.flush().unwrap();
thread::sleep(Duration::from_secs(10));
}
+8 -1
View File
@@ -592,7 +592,7 @@ min_session_age_secs = 600
**LLM provider env** (opt-in):
```
AI_MEMORY_LLM_PROVIDER anthropic | anthropic-oauth | openai | openai-oauth | copilot |
AI_MEMORY_LLM_PROVIDER anthropic | anthropic-oauth | openai | openai-oauth | codex | copilot |
gemini | openai-compat | opencode
AI_MEMORY_LLM_MODEL optional when the provider has a default; e.g. claude-haiku-4-5, gpt-5.4-mini
ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / LLM_API_KEY
@@ -630,6 +630,7 @@ AI_MEMORY_LLM_HEADERS optional extra HTTP headers on every chat request, as
`llm_headers = [...]` in config.toml for that.
AI_MEMORY_RERANKER optional `llm`; reranks project/scopes query candidates
COPILOT_GITHUB_TOKEN optional GitHub token for copilot
AI_MEMORY_CODEX_EXECUTABLE optional Codex executable; defaults to codex on PATH
GITHUB_COPILOT_API_TOKEN optional pre-minted Copilot API token
COPILOT_API_URL optional Copilot API base URL override
```
@@ -695,6 +696,12 @@ editor-plugin agent GitHub's Copilot API expects.
refresh token in `<data_dir>/auth.json`; it is separate from MCP/server bearer
auth and from OpenAI Platform API keys.
`codex` reads only the access token and account id from the Codex CLI-owned
`auth.json`, resolved from `CODEX_HOME` or the platform home. It never persists
Codex credentials. A single 401 recovery is serialized and delegated to
`codex app-server --stdio`, with bounded JSONL/stdout/stderr and a 30-second
maximum recovery timeout.
`copilot` uses `auth login copilot` or `COPILOT_GITHUB_TOKEN`, exchanges the
GitHub token through `/copilot_internal/v2/token`, and calls Copilot Chat with
the `vscode-chat` integration headers. The raw GitHub token is not sent to the
+24
View File
@@ -1554,6 +1554,7 @@ ai-memory works in three intensity tiers:
| **+ LLM consolidation** | LLM rewrites session pages as coherent narratives; PreCompact checkpoints; LLM-driven contradiction lint | `AI_MEMORY_LLM_PROVIDER=anthropic` + `ANTHROPIC_API_KEY` | ~$0.01–0.05 / session |
| **+ Anthropic via subscription** | Same LLM features using a Claude Pro/Max subscription instead of an API key | `AI_MEMORY_LLM_PROVIDER=anthropic-oauth` + `ANTHROPIC_OAUTH_TOKEN` | Uses your Claude subscription |
| **+ ChatGPT/Codex OAuth** | Same LLM features using a ChatGPT Pro/Plus login instead of an OpenAI Platform key | `AI_MEMORY_LLM_PROVIDER=openai-oauth` + `ai-memory auth login openai-oauth` | Uses your ChatGPT subscription |
| **+ Codex credential reuse** | Same LLM features using the Codex CLI-owned login without copying or owning its refresh token | `AI_MEMORY_LLM_PROVIDER=codex` + an authenticated Codex CLI | Uses your ChatGPT subscription |
| **+ GitHub Copilot** | Same LLM features using a GitHub Copilot subscription | `AI_MEMORY_LLM_PROVIDER=copilot` + `ai-memory auth login copilot` or `COPILOT_GITHUB_TOKEN` | Uses your Copilot subscription |
| **+ LLM reranking** | At most one relevance pass over up to 30 bounded project/scopes search candidates; normal order is preserved on invalid, failed, timed-out, or concurrency-saturated responses | `AI_MEMORY_RERANKER=llm` + any configured LLM provider | One LLM call per eligible query, at most four concurrently |
| **+ Hybrid retrieval** | Adds vector cosine similarity to FTS5 + entity + graph RRF. Better recall on paraphrased queries | `AI_MEMORY_EMBEDDING_PROVIDER=openai` + `OPENAI_API_KEY` (or `EMBEDDING_API_KEY`) | ~$0.0001 / page on backfill |
@@ -1568,6 +1569,7 @@ If you set only the provider, ai-memory picks a sensible default:
| `AI_MEMORY_LLM_PROVIDER=anthropic-oauth` | `claude-sonnet-4-6` | Anthropic via Claude subscription. Run `claude setup-token` once; set `ANTHROPIC_OAUTH_TOKEN` (or `CLAUDE_CODE_OAUTH_TOKEN`). No `ANTHROPIC_API_KEY` needed. Same `/v1/messages` endpoint, Bearer token auth. |
| `AI_MEMORY_LLM_PROVIDER=openai` | `gpt-5.4-mini` | Cheaper + faster alternative. Same parse reliability; mild over-classification on thin sessions. |
| `AI_MEMORY_LLM_PROVIDER=openai-oauth` | `gpt-5.5` | ChatGPT/Codex backend. Run `ai-memory auth login openai-oauth` once; ai-memory stores the refresh token in `<data_dir>/auth.json` and refreshes access tokens automatically. Optional `AI_MEMORY_LLM_REASONING_EFFORT` (`none`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`ultra`/`persistent`) is mapped to each provider's native reasoning field; omit it to keep the model default. |
| `AI_MEMORY_LLM_PROVIDER=codex` | `gpt-5.6-luna` | Reuses only `access_token` and `account_id` from Codex's `auth.json`; token renewal is delegated to `codex app-server --stdio`. |
| `AI_MEMORY_LLM_PROVIDER=copilot` | `gpt-5.5` | GitHub Copilot Chat backend. ai-memory stores a GitHub user token in `<data_dir>/auth.json`, exchanges it for a short-lived Copilot API token, and refreshes before expiry. |
| `AI_MEMORY_LLM_PROVIDER=gemini` | `gemini-3.5-flash` | Google's hosted option with a generous free tier. ai-memory disables Gemini 3.5 Flash's default dynamic thinking so hidden thought tokens do not truncate strict JSON. Set `GEMINI_API_KEY` (or `GOOGLE_API_KEY`). |
| `AI_MEMORY_LLM_PROVIDER=opencode` | `claude-sonnet-4-6` | [OpenCode](https://opencode.ai) cloud API. Defaults to the **Go** endpoint, `opencode.ai/zen/go/v1` — a cost-optimised model subset. GPT-5.6 Luna uses Go's Responses endpoint; other models use Chat Completions. For **Zen**'s full catalogue, set `AI_MEMORY_LLM_BASE_URL=https://opencode.ai/zen/v1` plus an `AI_MEMORY_LLM_MODEL` from it; the default model id is Go's. Requests identify ai-memory by version and reuse one session header across related attempts. Both endpoints take `OPENCODE_API_KEY` (key from `opencode.ai/auth`). Alias: `opencode-zen` — historical, and it selects Go like the others; the endpoint is chosen by the base URL, not the alias. |
@@ -1714,6 +1716,28 @@ Use `ai-memory auth status` to check whether a token is present and
> do not eat the JSON budget. Reserve high-effort reasoning for your
> coding agent.
### Codex credential reuse
The independent `codex` provider reads `$CODEX_HOME/auth.json`, falling back to
the platform home's `.codex/auth.json`. It materializes only
`tokens.access_token` and `tokens.account_id`, reloads them before every call,
and never copies or writes the file. On the first 401, it asks
`codex app-server --stdio` to refresh the Codex-owned credential and retries
the Responses request once.
```bash
export AI_MEMORY_LLM_PROVIDER=codex
export AI_MEMORY_LLM_MODEL=gpt-5.6-luna
export AI_MEMORY_LLM_REASONING_EFFORT=medium
ai-memory llm-test --provider codex --model gpt-5.6-luna --prompt "Reply with OK"
```
`AI_MEMORY_CODEX_EXECUTABLE` optionally selects another Codex binary. File
storage is supported; `auto` is supported when it resolves to the same
`auth.json`. Keyring-only and ephemeral storage are not supported. Docker is
outside the automatic setup path: both the executable and credentials must be
available inside the same container/environment.
### GitHub Copilot
`copilot` uses a GitHub user token, then exchanges it for a short-lived Copilot
+25 -1
View File
@@ -46,6 +46,7 @@ Recommended defaults:
| `anthropic-oauth` | `claude-sonnet-4-6` | Use a Claude Pro/Max subscription via `claude setup-token`, no API key. |
| `openai` | `gpt-5.4-mini` | Cheaper and faster hosted option. |
| `openai-oauth` | `gpt-5.5` | ChatGPT Pro/Plus/Codex backend via `ai-memory auth login openai-oauth`; no Platform API key. |
| `codex` | `gpt-5.6-luna` | Reuse the Codex CLI-owned `auth.json`; access-token refresh remains owned by `codex app-server`. |
| `copilot` | `gpt-5.5` | GitHub Copilot Chat backend via `ai-memory auth login copilot` or `COPILOT_GITHUB_TOKEN`; requires a Copilot subscription. |
| `gemini` | `gemini-3.5-flash` | Google-hosted option with a generous free tier. |
| `openai-compat` | no default | OpenRouter, Atlas Cloud, OrcaRouter, Ollama, vLLM, LM Studio, and other compatible endpoints. |
@@ -55,6 +56,29 @@ the ChatGPT/Codex Responses backend, not `api.openai.com`. For Docker quick
starts, run `ai-memory auth login openai-oauth` with the wrapper so the token
lands in the same `ai-memory-data` volume as the server.
`codex` is independent from `openai-oauth`: it never copies credentials into
ai-memory's data directory and never reads a refresh token or ID token. It
reads only `tokens.access_token` and `tokens.account_id` from
`$CODEX_HOME/auth.json`, or from the platform home's `.codex/auth.json` when
`CODEX_HOME` is unset or empty. Each request reloads the file. A first 401 may
trigger one serialized `codex app-server --stdio` recovery followed by one
retry; further 401 responses fail with a reauthentication hint. Override the
binary with `AI_MEMORY_CODEX_EXECUTABLE` when `codex` is not on `PATH`.
```bash
export AI_MEMORY_LLM_PROVIDER=codex
export AI_MEMORY_LLM_MODEL=gpt-5.6-luna
export AI_MEMORY_LLM_REASONING_EFFORT=medium
ai-memory llm-test --provider codex --model gpt-5.6-luna --prompt "Reply with OK"
```
Codex credential storage mode `file` is supported. `auto` works only when its
effective credential is present in `auth.json`; keyring-only and ephemeral
credentials are not read and produce an actionable missing-auth-file error.
Docker is not configured automatically: the Codex executable, `CODEX_HOME`,
and its credential file must all exist in the same container/environment as
ai-memory.
`anthropic-oauth` hits the same `/v1/messages` endpoint as `anthropic` but
authenticates with an OAuth bearer token instead of an API key. Run
`claude setup-token` once, then set `AI_MEMORY_LLM_PROVIDER=anthropic-oauth` and
@@ -80,7 +104,7 @@ uses the Copilot Chat endpoint with `vscode-chat` integration headers. You can
also set `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` on the server.
> [!TIP]
> **For the OAuth/subscription backends (`anthropic-oauth`, `openai-oauth`,
> **For the OAuth/subscription backends (`anthropic-oauth`, `openai-oauth`, `codex`,
> `copilot`), pick a small, fast model** via `AI_MEMORY_LLM_MODEL` — e.g.
> `claude-haiku-4-5` or `gpt-5-mini`. ai-memory's LLM work (consolidation,
> lint, explore) is summarisation, not hard reasoning, so a Haiku/mini-class
+45
View File
@@ -0,0 +1,45 @@
# Codex provider TDD evidence
Date: 2026-09-11
This development branch intentionally has no issue/PR reference and no
`CHANGELOG.md` entry yet. It is a local validation branch, not merge-ready.
## RED
Commit: `3b215cb8 test: add codex provider configuration reproducers`
Command:
```text
cargo test -p ai-memory-llm codex_auth_round_trips_only_resolved_paths --no-default-features
```
Expected failure observed: Rust reported that `ProviderAuth::codex`,
`AuthRequirement::CodexAuthFile`, and `ProviderChoice::Codex` did not exist.
## GREEN
Focused commands after implementation:
```text
cargo test -p ai-memory-llm codex::tests --no-default-features
cargo test -p ai-memory-llm openai_oauth::tests --no-default-features
cargo test -p ai-memory-cli codex --no-default-features
```
Observed results:
- Codex unit/recovery suite: 8 passed.
- Existing openai-oauth regression suite: 18 passed.
- Codex-related CLI/config tests: 17 passed.
The recovery suite compiles a fake Codex executable from Rust source. It runs
without network access or a real home directory and covers handshake messages,
interleaved notifications, wrong IDs, invalid and oversized output, stderr
limits, premature process exit, timeout, process cleanup, and auth-file
rotation. HTTP mocks cover account headers, model, reasoning, SSE, structured
output, reload-before-retry, and the one-retry ceiling.
Full workspace gates and live text/structured smoke results are appended only
after they run successfully.