Files
codex/prs/bolinfest/PR-1712.md
2025-09-02 15:17:45 -07:00

1770 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #1712: Add support for a separate chatgpt auth endpoint
- URL: https://github.com/openai/codex/pull/1712
- Author: pakrym-oai
- Created: 2025-07-28 20:30:19 UTC
- Updated: 2025-07-30 19:40:24 UTC
- Changes: +573/-283, Files changed: 19, Commits: 12
## Description
Adds a `CodexAuth` type that encapsulates information about available auth modes and logic for refreshing the token.
Changes `Responses` API to send requests to different endpoints based on the auth type.
Updates login_with_chatgpt to support API-less mode and skip the key exchange.
## Full Diff
```diff
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 653c3e4ef2..6019979909 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -673,7 +673,9 @@ dependencies = [
"async-channel",
"base64 0.22.1",
"bytes",
+ "chrono",
"codex-apply-patch",
+ "codex-login",
"codex-mcp-client",
"core_test_support",
"dirs",
diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs
index 4c4cb4c4c3..907783bb81 100644
--- a/codex-rs/chatgpt/src/chatgpt_client.rs
+++ b/codex-rs/chatgpt/src/chatgpt_client.rs
@@ -21,10 +21,14 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
let token =
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
+ let account_id = token.account_id.ok_or_else(|| {
+ anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`")
+ });
+
let response = client
.get(&url)
.bearer_auth(&token.access_token)
- .header("chatgpt-account-id", &token.account_id)
+ .header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.header("User-Agent", "codex-cli")
.send()
diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs
index adf9a6ba96..55ebc22a08 100644
--- a/codex-rs/chatgpt/src/chatgpt_token.rs
+++ b/codex-rs/chatgpt/src/chatgpt_token.rs
@@ -18,7 +18,10 @@ pub fn set_chatgpt_token_data(value: TokenData) {
/// Initialize the ChatGPT token from auth.json file
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> {
- let auth_json = codex_login::try_read_auth_json(codex_home).await?;
- set_chatgpt_token_data(auth_json.tokens.clone());
+ let auth = codex_login::load_auth(codex_home)?;
+ if let Some(auth) = auth {
+ let token_data = auth.get_token_data().await?;
+ set_chatgpt_token_data(token_data);
+ }
Ok(())
}
diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs
index 64b292d50b..291e1680f1 100644
--- a/codex-rs/cli/src/proto.rs
+++ b/codex-rs/cli/src/proto.rs
@@ -9,6 +9,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::Submission;
use codex_core::util::notify_on_sigint;
+use codex_login::load_auth;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -35,8 +36,9 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
+ let auth = load_auth(&config.codex_home)?;
let ctrl_c = notify_on_sigint();
- let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await?;
+ let CodexSpawnOk { codex, .. } = Codex::spawn(config, auth, ctrl_c.clone()).await?;
let codex = Arc::new(codex);
// Task that reads JSON lines from stdin and forwards to Submission Queue
diff --git a/codex-rs/config.md b/codex-rs/config.md
index c45d81180d..1a407a239b 100644
--- a/codex-rs/config.md
+++ b/codex-rs/config.md
@@ -110,12 +110,15 @@ stream_idle_timeout_ms = 300000 # 5m idle timeout
```
#### request_max_retries
+
How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`.
#### stream_max_retries
+
Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `10`.
#### stream_idle_timeout_ms
+
How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes).
## model_provider
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index 2e0489c9b5..5ebb5ef63d 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -17,6 +17,8 @@ base64 = "0.22"
bytes = "1.10.1"
codex-apply-patch = { path = "../apply-patch" }
codex-mcp-client = { path = "../mcp-client" }
+chrono = { version = "0.4", features = ["serde"] }
+codex-login = { path = "../login" }
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index aa31b67ecb..72104da254 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -3,6 +3,8 @@ use std::path::Path;
use std::time::Duration;
use bytes::Bytes;
+use codex_login::AuthMode;
+use codex_login::CodexAuth;
use eventsource_stream::Eventsource;
use futures::prelude::*;
use reqwest::StatusCode;
@@ -28,6 +30,7 @@ use crate::config::Config;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::error::CodexErr;
+use crate::error::EnvVarError;
use crate::error::Result;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_provider_info::ModelProviderInfo;
@@ -41,6 +44,7 @@ use std::sync::Arc;
#[derive(Clone)]
pub struct ModelClient {
config: Arc<Config>,
+ auth: Option<CodexAuth>,
client: reqwest::Client,
provider: ModelProviderInfo,
session_id: Uuid,
@@ -51,6 +55,7 @@ pub struct ModelClient {
impl ModelClient {
pub fn new(
config: Arc<Config>,
+ auth: Option<CodexAuth>,
provider: ModelProviderInfo,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
@@ -58,6 +63,7 @@ impl ModelClient {
) -> Self {
Self {
config,
+ auth,
client: reqwest::Client::new(),
provider,
session_id,
@@ -115,6 +121,25 @@ impl ModelClient {
return stream_from_fixture(path, self.provider.clone()).await;
}
+ let auth = self.auth.as_ref().ok_or_else(|| {
+ CodexErr::EnvVar(EnvVarError {
+ var: "OPENAI_API_KEY".to_string(),
+ instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".to_string()),
+ })
+ })?;
+
+ let store = prompt.store && auth.mode != AuthMode::ChatGPT;
+
+ let base_url = match self.provider.base_url.clone() {
+ Some(url) => url,
+ None => match auth.mode {
+ AuthMode::ChatGPT => "https://chatgpt.com/backend-api/codex".to_string(),
+ AuthMode::ApiKey => "https://api.openai.com/v1".to_string(),
+ },
+ };
+
+ let token = auth.get_token().await?;
+
let full_instructions = prompt.get_full_instructions(&self.config.model);
let tools_json = create_tools_json_for_responses_api(
prompt,
@@ -125,7 +150,7 @@ impl ModelClient {
// Request encrypted COT if we are not storing responses,
// otherwise reasoning items will be referenced by ID
- let include = if !prompt.store && reasoning.is_some() {
+ let include: Vec<String> = if !store && reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
vec![]
@@ -139,8 +164,7 @@ impl ModelClient {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning,
- store: prompt.store,
- // TODO: make this configurable
+ store,
stream: true,
include,
};
@@ -153,17 +177,21 @@ impl ModelClient {
let mut attempt = 0;
let max_retries = self.provider.request_max_retries();
+
loop {
attempt += 1;
let req_builder = self
- .provider
- .create_request_builder(&self.client)?
+ .client
+ .post(format!("{base_url}/responses"))
.header("OpenAI-Beta", "responses=experimental")
.header("session_id", self.session_id.to_string())
+ .bearer_auth(&token)
.header(reqwest::header::ACCEPT, "text/event-stream")
.json(&payload);
+ let req_builder = self.provider.apply_http_headers(req_builder);
+
let res = req_builder.send().await;
if let Ok(resp) = &res {
trace!(
@@ -572,7 +600,7 @@ mod tests {
let provider = ModelProviderInfo {
name: "test".to_string(),
- base_url: "https://test.com".to_string(),
+ base_url: Some("https://test.com".to_string()),
env_key: Some("TEST_API_KEY".to_string()),
env_key_instructions: None,
wire_api: WireApi::Responses,
@@ -582,6 +610,7 @@ mod tests {
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
+ requires_auth: false,
};
let events = collect_events(
@@ -631,7 +660,7 @@ mod tests {
let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
let provider = ModelProviderInfo {
name: "test".to_string(),
- base_url: "https://test.com".to_string(),
+ base_url: Some("https://test.com".to_string()),
env_key: Some("TEST_API_KEY".to_string()),
env_key_instructions: None,
wire_api: WireApi::Responses,
@@ -641,6 +670,7 @@ mod tests {
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
+ requires_auth: false,
};
let events = collect_events(&[sse1.as_bytes()], provider).await;
@@ -733,7 +763,7 @@ mod tests {
let provider = ModelProviderInfo {
name: "test".to_string(),
- base_url: "https://test.com".to_string(),
+ base_url: Some("https://test.com".to_string()),
env_key: Some("TEST_API_KEY".to_string()),
env_key_instructions: None,
wire_api: WireApi::Responses,
@@ -743,6 +773,7 @@ mod tests {
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
+ requires_auth: false,
};
let out = run_sse(evs, provider).await;
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 6efc878fb5..92ca7bf88b 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -15,6 +15,7 @@ use async_channel::Sender;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
+use codex_login::CodexAuth;
use futures::prelude::*;
use mcp_types::CallToolResult;
use serde::Serialize;
@@ -103,7 +104,11 @@ pub struct CodexSpawnOk {
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
- pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<CodexSpawnOk> {
+ pub async fn spawn(
+ config: Config,
+ auth: Option<CodexAuth>,
+ ctrl_c: Arc<Notify>,
+ ) -> CodexResult<CodexSpawnOk> {
// experimental resume path (undocumented)
let resume_path = config.experimental_resume.clone();
info!("resume_path: {resume_path:?}");
@@ -132,7 +137,7 @@ impl Codex {
// Generate a unique ID for the lifetime of this Codex session.
let session_id = Uuid::new_v4();
tokio::spawn(submission_loop(
- session_id, config, rx_sub, tx_event, ctrl_c,
+ session_id, config, auth, rx_sub, tx_event, ctrl_c,
));
let codex = Codex {
next_id: AtomicU64::new(0),
@@ -525,6 +530,7 @@ impl AgentTask {
async fn submission_loop(
mut session_id: Uuid,
config: Arc<Config>,
+ auth: Option<CodexAuth>,
rx_sub: Receiver<Submission>,
tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
@@ -636,6 +642,7 @@ async fn submission_loop(
let client = ModelClient::new(
config.clone(),
+ auth.clone(),
provider.clone(),
model_reasoning_effort,
model_reasoning_summary,
diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs
index b80579297a..1e26a9ebed 100644
--- a/codex-rs/core/src/codex_wrapper.rs
+++ b/codex-rs/core/src/codex_wrapper.rs
@@ -6,6 +6,7 @@ use crate::config::Config;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::util::notify_on_sigint;
+use codex_login::load_auth;
use tokio::sync::Notify;
use uuid::Uuid;
@@ -25,11 +26,12 @@ pub struct CodexConversation {
/// that callers can surface the information to the UI.
pub async fn init_codex(config: Config) -> anyhow::Result<CodexConversation> {
let ctrl_c = notify_on_sigint();
+ let auth = load_auth(&config.codex_home)?;
let CodexSpawnOk {
codex,
init_id,
session_id,
- } = Codex::spawn(config, ctrl_c.clone()).await?;
+ } = Codex::spawn(config, auth, ctrl_c.clone()).await?;
// The first event must be `SessionInitialized`. Validate and forward it to
// the caller so that they can display it in the conversation history.
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index 53ca8d5ba9..a65ec09674 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -526,6 +526,7 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
+
experimental_resume,
include_plan_tool: include_plan_tool.unwrap_or(false),
};
@@ -794,7 +795,7 @@ disable_response_storage = true
let openai_chat_completions_provider = ModelProviderInfo {
name: "OpenAI using Chat Completions".to_string(),
- base_url: "https://api.openai.com/v1".to_string(),
+ base_url: Some("https://api.openai.com/v1".to_string()),
env_key: Some("OPENAI_API_KEY".to_string()),
wire_api: crate::WireApi::Chat,
env_key_instructions: None,
@@ -804,6 +805,7 @@ disable_response_storage = true
request_max_retries: Some(4),
stream_max_retries: Some(10),
stream_idle_timeout_ms: Some(300_000),
+ requires_auth: false,
};
let model_provider_map = {
let mut model_provider_map = built_in_model_providers();
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index b2dbded5f1..ffe64d7c90 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -30,8 +30,8 @@ mod message_history;
mod model_provider_info;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
+pub use model_provider_info::built_in_model_providers;
mod models;
-pub mod openai_api_key;
mod openai_model_info;
mod openai_tools;
pub mod plan_tool;
diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs
index 72ef58c60a..4640f53ad7 100644
--- a/codex-rs/core/src/model_provider_info.rs
+++ b/codex-rs/core/src/model_provider_info.rs
@@ -12,7 +12,6 @@ use std::env::VarError;
use std::time::Duration;
use crate::error::EnvVarError;
-use crate::openai_api_key::get_openai_api_key;
/// Value for the `OpenAI-Originator` header that is sent with requests to
/// OpenAI.
@@ -30,7 +29,7 @@ const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
- /// The experimental "Responses" API exposed by OpenAI at `/v1/responses`.
+ /// The Responses API exposed by OpenAI at `/v1/responses`.
Responses,
/// Regular Chat Completions compatible with `/v1/chat/completions`.
@@ -44,7 +43,7 @@ pub struct ModelProviderInfo {
/// Friendly display name.
pub name: String,
/// Base URL for the provider's OpenAI-compatible API.
- pub base_url: String,
+ pub base_url: Option<String>,
/// Environment variable that stores the user's API key for this provider.
pub env_key: Option<String>,
@@ -78,6 +77,10 @@ pub struct ModelProviderInfo {
/// Idle timeout (in milliseconds) to wait for activity on a streaming response before treating
/// the connection as lost.
pub stream_idle_timeout_ms: Option<u64>,
+
+ /// Whether this provider requires some form of standard authentication (API key, ChatGPT token).
+ #[serde(default)]
+ pub requires_auth: bool,
}
impl ModelProviderInfo {
@@ -93,11 +96,11 @@ impl ModelProviderInfo {
&'a self,
client: &'a reqwest::Client,
) -> crate::error::Result<reqwest::RequestBuilder> {
- let api_key = self.api_key()?;
-
let url = self.get_full_url();
let mut builder = client.post(url);
+
+ let api_key = self.api_key()?;
if let Some(key) = api_key {
builder = builder.bearer_auth(key);
}
@@ -117,9 +120,15 @@ impl ModelProviderInfo {
.join("&");
format!("?{full_params}")
});
- let base_url = &self.base_url;
+ let base_url = self
+ .base_url
+ .clone()
+ .unwrap_or("https://api.openai.com/v1".to_string());
+
match self.wire_api {
- WireApi::Responses => format!("{base_url}/responses{query_string}"),
+ WireApi::Responses => {
+ format!("{base_url}/responses{query_string}")
+ }
WireApi::Chat => format!("{base_url}/chat/completions{query_string}"),
}
}
@@ -127,7 +136,10 @@ impl ModelProviderInfo {
/// Apply provider-specific HTTP headers (both static and environment-based)
/// onto an existing `reqwest::RequestBuilder` and return the updated
/// builder.
- fn apply_http_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
+ pub fn apply_http_headers(
+ &self,
+ mut builder: reqwest::RequestBuilder,
+ ) -> reqwest::RequestBuilder {
if let Some(extra) = &self.http_headers {
for (k, v) in extra {
builder = builder.header(k, v);
@@ -152,11 +164,7 @@ impl ModelProviderInfo {
fn api_key(&self) -> crate::error::Result<Option<String>> {
match &self.env_key {
Some(env_key) => {
- let env_value = if env_key == crate::openai_api_key::OPENAI_API_KEY_ENV_VAR {
- get_openai_api_key().map_or_else(|| Err(VarError::NotPresent), Ok)
- } else {
- std::env::var(env_key)
- };
+ let env_value = std::env::var(env_key);
env_value
.and_then(|v| {
if v.trim().is_empty() {
@@ -204,47 +212,51 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
// providers are bundled with Codex CLI, so we only include the OpenAI
// provider by default. Users are encouraged to add to `model_providers`
// in config.toml to add their own providers.
- [
- (
- "openai",
- P {
- name: "OpenAI".into(),
- // Allow users to override the default OpenAI endpoint by
- // exporting `OPENAI_BASE_URL`. This is useful when pointing
- // Codex at a proxy, mock server, or Azure-style deployment
- // without requiring a full TOML override for the built-in
- // OpenAI provider.
- base_url: std::env::var("OPENAI_BASE_URL")
- .ok()
- .filter(|v| !v.trim().is_empty())
- .unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
- env_key: Some("OPENAI_API_KEY".into()),
- env_key_instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".into()),
- wire_api: WireApi::Responses,
- query_params: None,
- http_headers: Some(
- [
- ("originator".to_string(), OPENAI_ORIGINATOR_HEADER.to_string()),
- ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
- ]
- .into_iter()
- .collect(),
- ),
- env_http_headers: Some(
- [
- ("OpenAI-Organization".to_string(), "OPENAI_ORGANIZATION".to_string()),
- ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()),
- ]
- .into_iter()
- .collect(),
- ),
- // Use global defaults for retry/timeout unless overridden in config.toml.
- request_max_retries: None,
- stream_max_retries: None,
- stream_idle_timeout_ms: None,
- },
- ),
- ]
+ [(
+ "openai",
+ P {
+ name: "OpenAI".into(),
+ // Allow users to override the default OpenAI endpoint by
+ // exporting `OPENAI_BASE_URL`. This is useful when pointing
+ // Codex at a proxy, mock server, or Azure-style deployment
+ // without requiring a full TOML override for the built-in
+ // OpenAI provider.
+ base_url: std::env::var("OPENAI_BASE_URL")
+ .ok()
+ .filter(|v| !v.trim().is_empty()),
+ env_key: None,
+ env_key_instructions: None,
+ wire_api: WireApi::Responses,
+ query_params: None,
+ http_headers: Some(
+ [
+ (
+ "originator".to_string(),
+ OPENAI_ORIGINATOR_HEADER.to_string(),
+ ),
+ ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
+ ]
+ .into_iter()
+ .collect(),
+ ),
+ env_http_headers: Some(
+ [
+ (
+ "OpenAI-Organization".to_string(),
+ "OPENAI_ORGANIZATION".to_string(),
+ ),
+ ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()),
+ ]
+ .into_iter()
+ .collect(),
+ ),
+ // Use global defaults for retry/timeout unless overridden in config.toml.
+ request_max_retries: None,
+ stream_max_retries: None,
+ stream_idle_timeout_ms: None,
+ requires_auth: true,
+ },
+ )]
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
@@ -264,7 +276,7 @@ base_url = "http://localhost:11434/v1"
"#;
let expected_provider = ModelProviderInfo {
name: "Ollama".into(),
- base_url: "http://localhost:11434/v1".into(),
+ base_url: Some("http://localhost:11434/v1".into()),
env_key: None,
env_key_instructions: None,
wire_api: WireApi::Chat,
@@ -274,6 +286,7 @@ base_url = "http://localhost:11434/v1"
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
+ requires_auth: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -290,7 +303,7 @@ query_params = { api-version = "2025-04-01-preview" }
"#;
let expected_provider = ModelProviderInfo {
name: "Azure".into(),
- base_url: "https://xxxxx.openai.azure.com/openai".into(),
+ base_url: Some("https://xxxxx.openai.azure.com/openai".into()),
env_key: Some("AZURE_OPENAI_API_KEY".into()),
env_key_instructions: None,
wire_api: WireApi::Chat,
@@ -302,6 +315,7 @@ query_params = { api-version = "2025-04-01-preview" }
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
+ requires_auth: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -319,7 +333,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
"#;
let expected_provider = ModelProviderInfo {
name: "Example".into(),
- base_url: "https://example.com".into(),
+ base_url: Some("https://example.com".into()),
env_key: Some("API_KEY".into()),
env_key_instructions: None,
wire_api: WireApi::Chat,
@@ -333,6 +347,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
+ requires_auth: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
diff --git a/codex-rs/core/src/openai_api_key.rs b/codex-rs/core/src/openai_api_key.rs
deleted file mode 100644
index 728914c0f2..0000000000
--- a/codex-rs/core/src/openai_api_key.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-use std::env;
-use std::sync::LazyLock;
-use std::sync::RwLock;
-
-pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
-
-static OPENAI_API_KEY: LazyLock<RwLock<Option<String>>> = LazyLock::new(|| {
- let val = env::var(OPENAI_API_KEY_ENV_VAR)
- .ok()
- .and_then(|s| if s.is_empty() { None } else { Some(s) });
- RwLock::new(val)
-});
-
-pub fn get_openai_api_key() -> Option<String> {
- #![allow(clippy::unwrap_used)]
- OPENAI_API_KEY.read().unwrap().clone()
-}
-
-pub fn set_openai_api_key(value: String) {
- #![allow(clippy::unwrap_used)]
- if !value.is_empty() {
- *OPENAI_API_KEY.write().unwrap() = Some(value);
- }
-}
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
index 9de2d56036..fbe63fb326 100644
--- a/codex-rs/core/tests/client.rs
+++ b/codex-rs/core/tests/client.rs
@@ -1,11 +1,19 @@
+use std::path::PathBuf;
+
+use chrono::Utc;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ModelProviderInfo;
+use codex_core::built_in_model_providers;
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionConfiguredEvent;
+use codex_login::AuthDotJson;
+use codex_login::AuthMode;
+use codex_login::CodexAuth;
+use codex_login::TokenData;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
@@ -48,32 +56,23 @@ async fn includes_session_id_and_model_headers_in_request() {
.await;
let model_provider = ModelProviderInfo {
- name: "openai".into(),
- base_url: format!("{}/v1", server.uri()),
- // Environment variable that should exist in the test environment.
- // ModelClient will return an error if the environment variable for the
- // provider is not set.
- env_key: Some("PATH".into()),
- env_key_instructions: None,
- wire_api: codex_core::WireApi::Responses,
- query_params: None,
- http_headers: Some(
- [("originator".to_string(), "codex_cli_rs".to_string())]
- .into_iter()
- .collect(),
- ),
- env_http_headers: None,
- request_max_retries: Some(0),
- stream_max_retries: Some(0),
- stream_idle_timeout_ms: None,
+ base_url: Some(format!("{}/v1", server.uri())),
+ ..built_in_model_providers()["openai"].clone()
};
// Init session
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
+
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
+ let CodexSpawnOk { codex, .. } = Codex::spawn(
+ config,
+ Some(CodexAuth::from_api_key("Test API Key".to_string())),
+ ctrl_c.clone(),
+ )
+ .await
+ .unwrap();
codex
.submit(Op::UserInput {
@@ -95,15 +94,20 @@ async fn includes_session_id_and_model_headers_in_request() {
// get request from the server
let request = &server.received_requests().await.unwrap()[0];
- let request_body = request.headers.get("session_id").unwrap();
- let originator = request.headers.get("originator").unwrap();
+ let request_session_id = request.headers.get("session_id").unwrap();
+ let request_originator = request.headers.get("originator").unwrap();
+ let request_authorization = request.headers.get("authorization").unwrap();
assert!(current_session_id.is_some());
assert_eq!(
- request_body.to_str().unwrap(),
+ request_session_id.to_str().unwrap(),
current_session_id.as_ref().unwrap()
);
- assert_eq!(originator.to_str().unwrap(), "codex_cli_rs");
+ assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
+ assert_eq!(
+ request_authorization.to_str().unwrap(),
+ "Bearer Test API Key"
+ );
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -126,22 +130,9 @@ async fn includes_base_instructions_override_in_request() {
.await;
let model_provider = ModelProviderInfo {
- name: "openai".into(),
- base_url: format!("{}/v1", server.uri()),
- // Environment variable that should exist in the test environment.
- // ModelClient will return an error if the environment variable for the
- // provider is not set.
- env_key: Some("PATH".into()),
- env_key_instructions: None,
- wire_api: codex_core::WireApi::Responses,
- query_params: None,
- http_headers: None,
- env_http_headers: None,
- request_max_retries: Some(0),
- stream_max_retries: Some(0),
- stream_idle_timeout_ms: None,
+ base_url: Some(format!("{}/v1", server.uri())),
+ ..built_in_model_providers()["openai"].clone()
};
-
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
@@ -149,7 +140,13 @@ async fn includes_base_instructions_override_in_request() {
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
+ let CodexSpawnOk { codex, .. } = Codex::spawn(
+ config,
+ Some(CodexAuth::from_api_key("Test API Key".to_string())),
+ ctrl_c.clone(),
+ )
+ .await
+ .unwrap();
codex
.submit(Op::UserInput {
@@ -172,3 +169,108 @@ async fn includes_base_instructions_override_in_request() {
.contains("test instructions")
);
}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn chatgpt_auth_sends_correct_request() {
+ #![allow(clippy::unwrap_used)]
+
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
+ println!(
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
+ );
+ return;
+ }
+
+ // Mock server
+ let server = MockServer::start().await;
+
+ // First request must NOT include `previous_response_id`.
+ let first = ResponseTemplate::new(200)
+ .insert_header("content-type", "text/event-stream")
+ .set_body_raw(sse_completed("resp1"), "text/event-stream");
+
+ Mock::given(method("POST"))
+ .and(path("/api/codex/responses"))
+ .respond_with(first)
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let model_provider = ModelProviderInfo {
+ base_url: Some(format!("{}/api/codex", server.uri())),
+ ..built_in_model_providers()["openai"].clone()
+ };
+
+ // Init session
+ let codex_home = TempDir::new().unwrap();
+ let mut config = load_default_config_for_test(&codex_home);
+ config.model_provider = model_provider;
+ let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
+ let CodexSpawnOk { codex, .. } = Codex::spawn(
+ config,
+ Some(auth_from_token("Access Token".to_string())),
+ ctrl_c.clone(),
+ )
+ .await
+ .unwrap();
+
+ codex
+ .submit(Op::UserInput {
+ items: vec![InputItem::Text {
+ text: "hello".into(),
+ }],
+ })
+ .await
+ .unwrap();
+
+ let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await
+ else {
+ unreachable!()
+ };
+
+ let current_session_id = Some(session_id.to_string());
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ // get request from the server
+ let request = &server.received_requests().await.unwrap()[0];
+ let request_session_id = request.headers.get("session_id").unwrap();
+ let request_originator = request.headers.get("originator").unwrap();
+ let request_authorization = request.headers.get("authorization").unwrap();
+ let request_body = request.body_json::<serde_json::Value>().unwrap();
+
+ assert!(current_session_id.is_some());
+ assert_eq!(
+ request_session_id.to_str().unwrap(),
+ current_session_id.as_ref().unwrap()
+ );
+ assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
+ assert_eq!(
+ request_authorization.to_str().unwrap(),
+ "Bearer Access Token"
+ );
+ assert!(!request_body["store"].as_bool().unwrap());
+ assert!(request_body["stream"].as_bool().unwrap());
+ assert_eq!(
+ request_body["include"][0].as_str().unwrap(),
+ "reasoning.encrypted_content"
+ );
+}
+
+fn auth_from_token(id_token: String) -> CodexAuth {
+ CodexAuth::new(
+ None,
+ AuthMode::ChatGPT,
+ PathBuf::new(),
+ Some(AuthDotJson {
+ tokens: TokenData {
+ id_token,
+ access_token: "Access Token".to_string(),
+ refresh_token: "test".to_string(),
+ account_id: None,
+ },
+ last_refresh: Utc::now(),
+ openai_api_key: None,
+ }),
+ )
+}
diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs
index 9895343045..95408e20e5 100644
--- a/codex-rs/core/tests/live_agent.rs
+++ b/codex-rs/core/tests/live_agent.rs
@@ -50,7 +50,7 @@ async fn spawn_codex() -> Result<Codex, CodexErr> {
config.model_provider.request_max_retries = Some(2);
config.model_provider.stream_max_retries = Some(2);
let CodexSpawnOk { codex: agent, .. } =
- Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?;
+ Codex::spawn(config, None, std::sync::Arc::new(Notify::new())).await?;
Ok(agent)
}
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
index 8e5d83a03e..d2fc035569 100644
--- a/codex-rs/core/tests/stream_no_completed.rs
+++ b/codex-rs/core/tests/stream_no_completed.rs
@@ -10,6 +10,7 @@ use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
+use codex_login::CodexAuth;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture;
use core_test_support::load_sse_fixture_with_id;
@@ -75,7 +76,7 @@ async fn retries_on_early_close() {
let model_provider = ModelProviderInfo {
name: "openai".into(),
- base_url: format!("{}/v1", server.uri()),
+ base_url: Some(format!("{}/v1", server.uri())),
// Environment variable that should exist in the test environment.
// ModelClient will return an error if the environment variable for the
// provider is not set.
@@ -89,13 +90,20 @@ async fn retries_on_early_close() {
request_max_retries: Some(0),
stream_max_retries: Some(1),
stream_idle_timeout_ms: Some(2000),
+ requires_auth: false,
};
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
- let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c).await.unwrap();
+ let CodexSpawnOk { codex, .. } = Codex::spawn(
+ config,
+ Some(CodexAuth::from_api_key("Test API Key".to_string())),
+ ctrl_c,
+ )
+ .await
+ .unwrap();
codex
.submit(Op::UserInput {
diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs
index ab92ecf616..47dbbca9fb 100644
--- a/codex-rs/login/src/lib.rs
+++ b/codex-rs/login/src/lib.rs
@@ -1,20 +1,152 @@
use chrono::DateTime;
+
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
+use std::env;
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
+use std::path::PathBuf;
use std::process::Stdio;
+use std::sync::Arc;
+use std::sync::Mutex;
use std::time::Duration;
use tokio::process::Command;
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
+const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum AuthMode {
+ ApiKey,
+ ChatGPT,
+}
+
+#[derive(Debug, Clone)]
+pub struct CodexAuth {
+ pub api_key: Option<String>,
+ pub mode: AuthMode,
+ auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
+ auth_file: PathBuf,
+}
+
+impl PartialEq for CodexAuth {
+ fn eq(&self, other: &Self) -> bool {
+ self.mode == other.mode
+ }
+}
+
+impl CodexAuth {
+ pub fn new(
+ api_key: Option<String>,
+ mode: AuthMode,
+ auth_file: PathBuf,
+ auth_dot_json: Option<AuthDotJson>,
+ ) -> Self {
+ let auth_dot_json = Arc::new(Mutex::new(auth_dot_json));
+ Self {
+ api_key,
+ mode,
+ auth_file,
+ auth_dot_json,
+ }
+ }
+
+ pub fn from_api_key(api_key: String) -> Self {
+ Self {
+ api_key: Some(api_key),
+ mode: AuthMode::ApiKey,
+ auth_file: PathBuf::new(),
+ auth_dot_json: Arc::new(Mutex::new(None)),
+ }
+ }
+
+ pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
+ #[expect(clippy::unwrap_used)]
+ let auth_dot_json = self.auth_dot_json.lock().unwrap().clone();
+
+ match auth_dot_json {
+ Some(auth_dot_json) => {
+ if auth_dot_json.last_refresh < Utc::now() - chrono::Duration::days(28) {
+ let refresh_response = tokio::time::timeout(
+ Duration::from_secs(60),
+ try_refresh_token(auth_dot_json.tokens.refresh_token.clone()),
+ )
+ .await
+ .map_err(|_| {
+ std::io::Error::other("timed out while refreshing OpenAI API key")
+ })?
+ .map_err(std::io::Error::other)?;
+
+ let updated_auth_dot_json = update_tokens(
+ &self.auth_file,
+ refresh_response.id_token,
+ refresh_response.access_token,
+ refresh_response.refresh_token,
+ )
+ .await?;
+
+ #[expect(clippy::unwrap_used)]
+ let mut auth_dot_json = self.auth_dot_json.lock().unwrap();
+ *auth_dot_json = Some(updated_auth_dot_json);
+ }
+ Ok(auth_dot_json.tokens.clone())
+ }
+ None => Err(std::io::Error::other("Token data is not available.")),
+ }
+ }
+
+ pub async fn get_token(&self) -> Result<String, std::io::Error> {
+ match self.mode {
+ AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
+ AuthMode::ChatGPT => {
+ let id_token = self.get_token_data().await?.access_token;
+
+ Ok(id_token)
+ }
+ }
+ }
+}
+
+// Loads the available auth information from the auth.json or OPENAI_API_KEY environment variable.
+pub fn load_auth(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
+ let auth_file = codex_home.join("auth.json");
+
+ let auth_dot_json = try_read_auth_json(&auth_file).ok();
+
+ let auth_json_api_key = auth_dot_json
+ .as_ref()
+ .and_then(|a| a.openai_api_key.clone())
+ .filter(|s| !s.is_empty());
+
+ let openai_api_key = env::var(OPENAI_API_KEY_ENV_VAR)
+ .ok()
+ .filter(|s| !s.is_empty())
+ .or(auth_json_api_key);
+
+ if openai_api_key.is_none() && auth_dot_json.is_none() {
+ return Ok(None);
+ }
+
+ let mode = if openai_api_key.is_some() {
+ AuthMode::ApiKey
+ } else {
+ AuthMode::ChatGPT
+ };
+
+ Ok(Some(CodexAuth {
+ api_key: openai_api_key,
+ mode,
+ auth_file,
+ auth_dot_json: Arc::new(Mutex::new(auth_dot_json)),
+ }))
+}
/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
/// environment variable set to the provided `codex_home` path. If the
@@ -25,14 +157,12 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
/// If `capture_output` is true, the subprocess's output will be captured and
/// recorded in memory. Otherwise, the subprocess's output will be sent to the
/// current process's stdout/stderr.
-pub async fn login_with_chatgpt(
- codex_home: &Path,
- capture_output: bool,
-) -> std::io::Result<String> {
+pub async fn login_with_chatgpt(codex_home: &Path, capture_output: bool) -> std::io::Result<()> {
let child = Command::new("python3")
.arg("-c")
.arg(SOURCE_FOR_PYTHON_SERVER)
.env("CODEX_HOME", codex_home)
+ .env("CODEX_CLIENT_ID", CLIENT_ID)
.stdin(Stdio::null())
.stdout(if capture_output {
Stdio::piped()
@@ -48,7 +178,7 @@ pub async fn login_with_chatgpt(
let output = child.wait_with_output().await?;
if output.status.success() {
- try_read_openai_api_key(codex_home).await
+ Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(std::io::Error::other(format!(
@@ -57,65 +187,54 @@ pub async fn login_with_chatgpt(
}
}
-/// Attempt to read the `OPENAI_API_KEY` from the `auth.json` file in the given
-/// `CODEX_HOME` directory, refreshing it, if necessary.
-pub async fn try_read_openai_api_key(codex_home: &Path) -> std::io::Result<String> {
- let auth_dot_json = try_read_auth_json(codex_home).await?;
- Ok(auth_dot_json.openai_api_key)
-}
-
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
/// Returns the full AuthDotJson structure after refreshing if necessary.
-pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result<AuthDotJson> {
- let auth_path = codex_home.join("auth.json");
- let mut file = std::fs::File::open(&auth_path)?;
+pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
+ let mut file = std::fs::File::open(auth_file)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
- if is_expired(&auth_dot_json) {
- let refresh_response =
- tokio::time::timeout(Duration::from_secs(60), try_refresh_token(&auth_dot_json))
- .await
- .map_err(|_| std::io::Error::other("timed out while refreshing OpenAI API key"))?
- .map_err(std::io::Error::other)?;
- let mut auth_dot_json = auth_dot_json;
- auth_dot_json.tokens.id_token = refresh_response.id_token;
- if let Some(refresh_token) = refresh_response.refresh_token {
- auth_dot_json.tokens.refresh_token = refresh_token;
- }
- auth_dot_json.last_refresh = Utc::now();
-
- let mut options = OpenOptions::new();
- options.truncate(true).write(true).create(true);
- #[cfg(unix)]
- {
- options.mode(0o600);
- }
+ Ok(auth_dot_json)
+}
- let json_data = serde_json::to_string(&auth_dot_json)?;
- {
- let mut file = options.open(&auth_path)?;
- file.write_all(json_data.as_bytes())?;
- file.flush()?;
- }
+async fn update_tokens(
+ auth_file: &Path,
+ id_token: String,
+ access_token: Option<String>,
+ refresh_token: Option<String>,
+) -> std::io::Result<AuthDotJson> {
+ let mut options = OpenOptions::new();
+ options.truncate(true).write(true).create(true);
+ #[cfg(unix)]
+ {
+ options.mode(0o600);
+ }
+ let mut auth_dot_json = try_read_auth_json(auth_file)?;
- Ok(auth_dot_json)
- } else {
- Ok(auth_dot_json)
+ auth_dot_json.tokens.id_token = id_token.to_string();
+ if let Some(access_token) = access_token {
+ auth_dot_json.tokens.access_token = access_token.to_string();
}
-}
+ if let Some(refresh_token) = refresh_token {
+ auth_dot_json.tokens.refresh_token = refresh_token.to_string();
+ }
+ auth_dot_json.last_refresh = Utc::now();
-fn is_expired(auth_dot_json: &AuthDotJson) -> bool {
- let last_refresh = auth_dot_json.last_refresh;
- last_refresh < Utc::now() - chrono::Duration::days(28)
+ let json_data = serde_json::to_string_pretty(&auth_dot_json)?;
+ {
+ let mut file = options.open(auth_file)?;
+ file.write_all(json_data.as_bytes())?;
+ file.flush()?;
+ }
+ Ok(auth_dot_json)
}
-async fn try_refresh_token(auth_dot_json: &AuthDotJson) -> std::io::Result<RefreshResponse> {
+async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
let refresh_request = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
- refresh_token: auth_dot_json.tokens.refresh_token.clone(),
+ refresh_token,
scope: "openid profile email",
};
@@ -150,24 +269,25 @@ struct RefreshRequest {
scope: &'static str,
}
-#[derive(Deserialize)]
+#[derive(Deserialize, Clone)]
struct RefreshResponse {
id_token: String,
+ access_token: Option<String>,
refresh_token: Option<String>,
}
/// Expected structure for $CODEX_HOME/auth.json.
-#[derive(Deserialize, Serialize)]
+#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct AuthDotJson {
#[serde(rename = "OPENAI_API_KEY")]
- pub openai_api_key: String,
+ pub openai_api_key: Option<String>,
pub tokens: TokenData,
pub last_refresh: DateTime<Utc>,
}
-#[derive(Deserialize, Serialize, Clone)]
+#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct TokenData {
/// This is a JWT.
pub id_token: String,
@@ -177,5 +297,5 @@ pub struct TokenData {
pub refresh_token: String,
- pub account_id: String,
+ pub account_id: Option<String>,
}
diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py
index ccb051c0af..2dbf5be58a 100644
--- a/codex-rs/login/src/login_with_chatgpt.py
+++ b/codex-rs/login/src/login_with_chatgpt.py
@@ -41,7 +41,6 @@
REQUIRED_PORT = 1455
URL_BASE = f"http://localhost:{REQUIRED_PORT}"
DEFAULT_ISSUER = "https://auth.openai.com"
-DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13
@@ -58,7 +57,7 @@ class TokenData:
class AuthBundle:
"""Aggregates authentication data produced after successful OAuth flow."""
- api_key: str
+ api_key: str | None
token_data: TokenData
last_refresh: str
@@ -78,12 +77,18 @@ def main() -> None:
eprint("ERROR: CODEX_HOME environment variable is not set")
sys.exit(1)
+ client_id = os.getenv("CODEX_CLIENT_ID")
+ if not client_id:
+ eprint("ERROR: CODEX_CLIENT_ID environment variable is not set")
+ sys.exit(1)
+
# Spawn server.
try:
httpd = _ApiKeyHTTPServer(
("127.0.0.1", REQUIRED_PORT),
_ApiKeyHTTPHandler,
codex_home=codex_home,
+ client_id=client_id,
verbose=args.verbose,
)
except OSError as e:
@@ -157,7 +162,7 @@ def do_GET(self) -> None: # noqa: N802 required by BaseHTTPRequestHandler
return
try:
- auth_bundle, success_url = self._exchange_code_for_api_key(code)
+ auth_bundle, success_url = self._exchange_code(code)
except Exception as exc: # noqa: BLE001 propagate to client
self.send_error(500, f"Token exchange failed: {exc}")
return
@@ -211,68 +216,22 @@ def log_message(self, fmt: str, *args): # type: ignore[override]
if getattr(self.server, "verbose", False): # type: ignore[attr-defined]
super().log_message(fmt, *args)
- def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
- """Perform token + token-exchange to obtain an OpenAI API key.
+ def _obtain_api_key(
+ self,
+ token_claims: Dict[str, Any],
+ access_claims: Dict[str, Any],
+ token_data: TokenData,
+ ) -> tuple[str | None, str | None]:
+ """Obtain an API key from the auth service.
- Returns (AuthBundle, success_url).
+ Returns (api_key, success_url) if successful, None otherwise.
"""
- token_endpoint = f"{self.server.issuer}/oauth/token"
-
- # 1. Authorization-code -> (id_token, access_token, refresh_token)
- data = urllib.parse.urlencode(
- {
- "grant_type": "authorization_code",
- "code": code,
- "redirect_uri": self.server.redirect_uri,
- "client_id": self.server.client_id,
- "code_verifier": self.server.pkce.code_verifier,
- }
- ).encode()
-
- token_data: TokenData
-
- with urllib.request.urlopen(
- urllib.request.Request(
- token_endpoint,
- data=data,
- method="POST",
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- )
- ) as resp:
- payload = json.loads(resp.read().decode())
-
- # Extract chatgpt_account_id from id_token
- id_token_parts = payload["id_token"].split(".")
- if len(id_token_parts) != 3:
- raise ValueError("Invalid ID token")
- id_token_claims = _decode_jwt_segment(id_token_parts[1])
- auth_claims = id_token_claims.get("https://api.openai.com/auth", {})
- chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
-
- token_data = TokenData(
- id_token=payload["id_token"],
- access_token=payload["access_token"],
- refresh_token=payload["refresh_token"],
- account_id=chatgpt_account_id,
- )
-
- access_token_parts = token_data.access_token.split(".")
- if len(access_token_parts) != 3:
- raise ValueError("Invalid access token")
-
- access_token_claims = _decode_jwt_segment(access_token_parts[1])
-
- token_claims = id_token_claims.get("https://api.openai.com/auth", {})
- access_claims = access_token_claims.get("https://api.openai.com/auth", {})
-
org_id = token_claims.get("organization_id")
- if not org_id:
- raise ValueError("Missing organization in id_token claims")
-
project_id = token_claims.get("project_id")
- if not project_id:
- raise ValueError("Missing project in id_token claims")
+
+ if not org_id or not project_id:
+ return (None, None)
random_id = secrets.token_hex(6)
@@ -292,7 +251,7 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
exchanged_access_token: str
with urllib.request.urlopen(
urllib.request.Request(
- token_endpoint,
+ self.server.token_endpoint,
data=exchange_data,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
@@ -340,6 +299,65 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
except Exception as exc: # pragma: no cover best-effort only
eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}")
+ return (exchanged_access_token, success_url)
+
+ def _exchange_code(self, code: str) -> tuple[AuthBundle, str]:
+ """Perform token + token-exchange to obtain an OpenAI API key.
+
+ Returns (AuthBundle, success_url).
+ """
+
+ # 1. Authorization-code -> (id_token, access_token, refresh_token)
+ data = urllib.parse.urlencode(
+ {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": self.server.redirect_uri,
+ "client_id": self.server.client_id,
+ "code_verifier": self.server.pkce.code_verifier,
+ }
+ ).encode()
+
+ token_data: TokenData
+
+ with urllib.request.urlopen(
+ urllib.request.Request(
+ self.server.token_endpoint,
+ data=data,
+ method="POST",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ ) as resp:
+ payload = json.loads(resp.read().decode())
+
+ # Extract chatgpt_account_id from id_token
+ id_token_parts = payload["id_token"].split(".")
+ if len(id_token_parts) != 3:
+ raise ValueError("Invalid ID token")
+ id_token_claims = _decode_jwt_segment(id_token_parts[1])
+ auth_claims = id_token_claims.get("https://api.openai.com/auth", {})
+ chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
+
+ token_data = TokenData(
+ id_token=payload["id_token"],
+ access_token=payload["access_token"],
+ refresh_token=payload["refresh_token"],
+ account_id=chatgpt_account_id,
+ )
+
+ access_token_parts = token_data.access_token.split(".")
+ if len(access_token_parts) != 3:
+ raise ValueError("Invalid access token")
+
+ access_token_claims = _decode_jwt_segment(access_token_parts[1])
+
+ token_claims = id_token_claims.get("https://api.openai.com/auth", {})
+ access_claims = access_token_claims.get("https://api.openai.com/auth", {})
+
+ exchanged_access_token, success_url = self._obtain_api_key(
+ token_claims, access_claims, token_data
+ )
+
# Persist refresh_token/id_token for future use (redeem credits etc.)
last_refresh_str = (
datetime.datetime.now(datetime.timezone.utc)
@@ -353,7 +371,7 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
last_refresh=last_refresh_str,
)
- return (auth_bundle, success_url)
+ return (auth_bundle, success_url or f"{URL_BASE}/success")
def request_shutdown(self) -> None:
# shutdown() must be invoked from another thread to avoid
@@ -413,6 +431,7 @@ def __init__(
request_handler_class: type[http.server.BaseHTTPRequestHandler],
*,
codex_home: str,
+ client_id: str,
verbose: bool = False,
) -> None:
super().__init__(server_address, request_handler_class, bind_and_activate=True)
@@ -422,7 +441,8 @@ def __init__(
self.verbose: bool = verbose
self.issuer: str = DEFAULT_ISSUER
- self.client_id: str = DEFAULT_CLIENT_ID
+ self.token_endpoint: str = f"{self.issuer}/oauth/token"
+ self.client_id: str = client_id
port = server_address[1]
self.redirect_uri: str = f"http://localhost:{port}/auth/callback"
self.pkce: PkceCodes = _generate_pkce()
@@ -581,8 +601,8 @@ def maybe_redeem_credits(
granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0)
if granted and granted > 0:
eprint(
- f"""Thanks for being a ChatGPT {'Plus' if plan_type=='plus' else 'Pro'} subscriber!
-If you haven't already redeemed, you should receive {'$5' if plan_type=='plus' else '$50'} in API credits.
+ f"""Thanks for being a ChatGPT {"Plus" if plan_type == "plus" else "Pro"} subscriber!
+If you haven't already redeemed, you should receive {"$5" if plan_type == "plus" else "$50"} in API credits.
Credits: https://platform.openai.com/settings/organization/billing/credit-grants
More info: https://help.openai.com/en/articles/11381614""",
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 6c6c662154..d67576e2c6 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -6,16 +6,14 @@ use app::App;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_types::SandboxMode;
-use codex_core::openai_api_key::OPENAI_API_KEY_ENV_VAR;
-use codex_core::openai_api_key::get_openai_api_key;
-use codex_core::openai_api_key::set_openai_api_key;
use codex_core::protocol::AskForApproval;
use codex_core::util::is_inside_git_repo;
-use codex_login::try_read_openai_api_key;
+use codex_login::load_auth;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
+use tracing::error;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
@@ -143,7 +141,7 @@ pub async fn run_main(
.with(tui_layer)
.try_init();
- let show_login_screen = should_show_login_screen(&config).await;
+ let show_login_screen = should_show_login_screen(&config);
if show_login_screen {
std::io::stdout()
.write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?;
@@ -156,8 +154,8 @@ pub async fn run_main(
}
// Spawn a task to run the login command.
// Block until the login command is finished.
- let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?;
- set_openai_api_key(new_key);
+ codex_login::login_with_chatgpt(&config.codex_home, false).await?;
+
std::io::stdout().write_all(b"Login successful.\n")?;
}
@@ -220,28 +218,21 @@ fn restore() {
}
}
-async fn should_show_login_screen(config: &Config) -> bool {
- if is_in_need_of_openai_api_key(config) {
+#[allow(clippy::unwrap_used)]
+fn should_show_login_screen(config: &Config) -> bool {
+ if config.model_provider.requires_auth {
// Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
- if let Ok(openai_api_key) = try_read_openai_api_key(&codex_home).await {
- set_openai_api_key(openai_api_key);
- false
- } else {
- true
+ match load_auth(&codex_home) {
+ Ok(Some(_)) => false,
+ Ok(None) => true,
+ Err(err) => {
+ error!("Failed to read auth.json: {err}");
+ true
+ }
}
} else {
false
}
}
-
-fn is_in_need_of_openai_api_key(config: &Config) -> bool {
- let is_using_openai_key = config
- .model_provider
- .env_key
- .as_ref()
- .map(|s| s == OPENAI_API_KEY_ENV_VAR)
- .unwrap_or(false);
- is_using_openai_key && get_openai_api_key().is_none()
-}
```
## Review Comments
### codex-rs/core/Cargo.toml
- Created: 2025-07-30 17:09:58 UTC | Link: https://github.com/openai/codex/pull/1712#discussion_r2243363886
```diff
@@ -17,6 +17,8 @@ base64 = "0.22"
bytes = "1.10.1"
codex-apply-patch = { path = "../apply-patch" }
codex-mcp-client = { path = "../mcp-client" }
+codex-login = { path = "../login" }
+chrono = { version = "0.4", features = ["serde"] }
```
> `chrono` before `codex-apply-patch`?
### codex-rs/core/src/client.rs
- Created: 2025-07-28 23:04:36 UTC | Link: https://github.com/openai/codex/pull/1712#discussion_r2238003178
```diff
@@ -114,13 +120,32 @@ impl ModelClient {
return stream_from_fixture(path, self.provider.clone()).await;
}
+ let auth = self.auth.as_ref().ok_or_else(|| {
```
> Will this break people who are using Codex CLI with other providers?
### codex-rs/core/src/model_provider_info.rs
- Created: 2025-07-30 17:18:55 UTC | Link: https://github.com/openai/codex/pull/1712#discussion_r2243380986
```diff
@@ -204,47 +212,49 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
// providers are bundled with Codex CLI, so we only include the OpenAI
// provider by default. Users are encouraged to add to `model_providers`
// in config.toml to add their own providers.
- [
- (
- "openai",
- P {
- name: "OpenAI".into(),
- // Allow users to override the default OpenAI endpoint by
- // exporting `OPENAI_BASE_URL`. This is useful when pointing
- // Codex at a proxy, mock server, or Azure-style deployment
- // without requiring a full TOML override for the built-in
- // OpenAI provider.
- base_url: std::env::var("OPENAI_BASE_URL")
- .ok()
- .filter(|v| !v.trim().is_empty())
```
> We should still map `""` to `None` probably?
### codex-rs/login/src/lib.rs
- Created: 2025-07-30 17:22:49 UTC | Link: https://github.com/openai/codex/pull/1712#discussion_r2243390657
```diff
@@ -1,20 +1,152 @@
use chrono::DateTime;
+
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
+use std::env;
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
+use std::path::PathBuf;
use std::process::Stdio;
+use std::sync::Arc;
+use std::sync::Mutex;
use std::time::Duration;
use tokio::process::Command;
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
+const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum AuthMode {
+ ApiKey,
+ ChatGPT,
+}
+
+#[derive(Debug, Clone)]
+pub struct CodexAuth {
+ pub api_key: Option<String>,
+ pub mode: AuthMode,
+ auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
+ auth_file: PathBuf,
+}
+
+impl PartialEq for CodexAuth {
```
> This feels a bit dicey...
- Created: 2025-07-30 17:24:32 UTC | Link: https://github.com/openai/codex/pull/1712#discussion_r2243395901
```diff
@@ -1,20 +1,152 @@
use chrono::DateTime;
+
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
+use std::env;
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
+use std::path::PathBuf;
use std::process::Stdio;
+use std::sync::Arc;
+use std::sync::Mutex;
use std::time::Duration;
use tokio::process::Command;
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
+const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum AuthMode {
+ ApiKey,
+ ChatGPT,
+}
+
+#[derive(Debug, Clone)]
+pub struct CodexAuth {
+ pub api_key: Option<String>,
+ pub mode: AuthMode,
+ auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
```
> Rather than a `Mutex`, I would consider advisory locking. Then for the methods that need this, I would pass in `cargo_home: PathBuf` and dynamically construct the path to `auth.json`.