mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
feat: add Codex credential-reuse provider
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¤t) {
|
||||
let _guard = self.recovery.lock().await;
|
||||
current = read_credentials(&self.auth.auth_file)?;
|
||||
if same_access_token(&initial, ¤t) {
|
||||
recover_with_codex(&self.auth, self.timeout).await?;
|
||||
current = read_credentials(&self.auth.auth_file)?;
|
||||
if same_access_token(&initial, ¤t) {
|
||||
return Err(codex_reauth_error(
|
||||
"Codex completed recovery without replacing the access token",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.post_once(¤t, 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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user