Merge remote-tracking branch 'origin/main' into etraut/live_skill_update

This commit is contained in:
Eric Traut
2026-01-30 09:52:04 -08:00
20 changed files with 451 additions and 236 deletions
@@ -24,10 +24,6 @@ impl GitSha {
}
/// Authentication mode for OpenAI-backed providers.
///
/// This is used internally to determine the base URL for generating responses, and to gate
/// ChatGPT-only behaviors like rate limits and available models (as opposed to API key-based
/// auth).
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
@@ -133,6 +133,7 @@ use codex_app_server_protocol::build_turns_from_event_msgs;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::Cursor as RolloutCursor;
use codex_core::InitialHistory;
@@ -686,7 +687,11 @@ impl CodexMessageProcessor {
.await;
let payload = AuthStatusChangeNotification {
auth_method: self.auth_manager.auth_cached().map(|auth| auth.mode),
auth_method: self
.auth_manager
.auth_cached()
.as_ref()
.map(CodexAuth::api_auth_mode),
};
self.outgoing
.send_server_notification(ServerNotification::AuthStatusChange(payload))
@@ -716,7 +721,11 @@ impl CodexMessageProcessor {
.await;
let payload_v2 = AccountUpdatedNotification {
auth_mode: self.auth_manager.auth_cached().map(|auth| auth.mode),
auth_mode: self
.auth_manager
.auth_cached()
.as_ref()
.map(CodexAuth::api_auth_mode),
};
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
@@ -812,7 +821,10 @@ impl CodexMessageProcessor {
auth_manager.reload();
// Notify clients with the actual current auth mode.
let current_auth_method = auth_manager.auth_cached().map(|a| a.mode);
let current_auth_method = auth_manager
.auth_cached()
.as_ref()
.map(CodexAuth::api_auth_mode);
let payload = AuthStatusChangeNotification {
auth_method: current_auth_method,
};
@@ -902,7 +914,10 @@ impl CodexMessageProcessor {
auth_manager.reload();
// Notify clients with the actual current auth mode.
let current_auth_method = auth_manager.auth_cached().map(|a| a.mode);
let current_auth_method = auth_manager
.auth_cached()
.as_ref()
.map(CodexAuth::api_auth_mode);
let payload_v2 = AccountUpdatedNotification {
auth_mode: current_auth_method,
};
@@ -1106,7 +1121,11 @@ impl CodexMessageProcessor {
}
// Reflect the current auth method after logout (likely None).
Ok(self.auth_manager.auth_cached().map(|auth| auth.mode))
Ok(self
.auth_manager
.auth_cached()
.as_ref()
.map(CodexAuth::api_auth_mode))
}
async fn logout_v1(&mut self, request_id: RequestId) {
@@ -1178,7 +1197,7 @@ impl CodexMessageProcessor {
} else {
match self.auth_manager.auth().await {
Some(auth) => {
let auth_mode = auth.mode;
let auth_mode = auth.api_auth_mode();
let (reported_auth_method, token_opt) = match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
@@ -1225,9 +1244,9 @@ impl CodexMessageProcessor {
}
let account = match self.auth_manager.auth_cached() {
Some(auth) => Some(match auth.mode {
AuthMode::ApiKey => Account::ApiKey {},
AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => {
Some(auth) => Some(match auth {
CodexAuth::ApiKey(_) => Account::ApiKey {},
CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => {
let email = auth.get_account_email();
let plan_type = auth.account_plan_type();
@@ -1286,7 +1305,7 @@ impl CodexMessageProcessor {
});
};
if !matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) {
if !auth.is_chatgpt_auth() {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "chatgpt authentication required to read rate limits".to_string(),
+1 -1
View File
@@ -225,7 +225,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => match auth.mode {
Ok(Some(auth)) => match auth.api_auth_mode() {
AuthMode::ApiKey => match auth.get_token() {
Ok(api_key) => {
eprintln!("Logged in using an API key - {}", safe_format_key(&api_key));
+225 -115
View File
@@ -13,8 +13,9 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::AuthMode as ApiAuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
pub use crate::auth::storage::AuthCredentialsStoreMode;
@@ -35,19 +36,50 @@ use codex_protocol::account::PlanType as AccountPlanType;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct CodexAuth {
pub mode: AuthMode,
/// Account type for the current user.
///
/// This is used internally to determine the base URL for generating responses,
/// and to gate ChatGPT-only behaviors like rate limits and available models (as
/// opposed to API key-based auth).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthMode {
ApiKey,
ChatGPT,
}
pub(crate) api_key: Option<String>,
pub(crate) auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
/// Authentication mechanism used by the current user.
#[derive(Debug, Clone)]
pub enum CodexAuth {
ApiKey(ApiKeyAuth),
ChatGpt(ChatGptAuth),
ChatGptAuthTokens(ChatGptAuthTokens),
}
#[derive(Debug, Clone)]
pub struct ApiKeyAuth {
api_key: String,
}
#[derive(Debug, Clone)]
pub struct ChatGptAuth {
state: ChatGptAuthState,
storage: Arc<dyn AuthStorageBackend>,
pub(crate) client: CodexHttpClient,
}
#[derive(Debug, Clone)]
pub struct ChatGptAuthTokens {
state: ChatGptAuthState,
}
#[derive(Debug, Clone)]
struct ChatGptAuthState {
auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
client: CodexHttpClient,
}
impl PartialEq for CodexAuth {
fn eq(&self, other: &Self) -> bool {
self.mode == other.mode
self.api_auth_mode() == other.api_auth_mode()
}
}
@@ -114,14 +146,78 @@ impl From<RefreshTokenError> for std::io::Error {
}
impl CodexAuth {
fn from_auth_dot_json(
codex_home: &Path,
auth_dot_json: AuthDotJson,
auth_credentials_store_mode: AuthCredentialsStoreMode,
client: CodexHttpClient,
) -> std::io::Result<Self> {
let auth_mode = auth_dot_json.resolved_mode();
if auth_mode == ApiAuthMode::ApiKey {
let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else {
return Err(std::io::Error::other("API key auth is missing a key."));
};
return Ok(CodexAuth::from_api_key_with_client(api_key, client));
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let state = ChatGptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client,
};
match auth_mode {
ApiAuthMode::ChatGPT => {
let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode);
Ok(Self::ChatGpt(ChatGptAuth { state, storage }))
}
ApiAuthMode::ChatgptAuthTokens => {
Ok(Self::ChatGptAuthTokens(ChatGptAuthTokens { state }))
}
ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"),
}
}
/// Loads the available auth information from auth storage.
pub fn from_auth_storage(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<Option<CodexAuth>> {
) -> std::io::Result<Option<Self>> {
load_auth(codex_home, false, auth_credentials_store_mode)
}
pub fn internal_auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => AuthMode::ChatGPT,
}
}
pub fn api_auth_mode(&self) -> ApiAuthMode {
match self {
Self::ApiKey(_) => ApiAuthMode::ApiKey,
Self::ChatGpt(_) => ApiAuthMode::ChatGPT,
Self::ChatGptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
}
}
pub fn is_chatgpt_auth(&self) -> bool {
self.internal_auth_mode() == AuthMode::ChatGPT
}
pub fn is_external_chatgpt_tokens(&self) -> bool {
matches!(self, Self::ChatGptAuthTokens(_))
}
/// Returns `None` is `is_internal_auth_mode() != AuthMode::ApiKey`.
pub fn api_key(&self) -> Option<&str> {
match self {
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => None,
}
}
/// Returns `Err` if `is_chatgpt_auth()` is false.
pub fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
match auth_dot_json {
@@ -134,20 +230,23 @@ impl CodexAuth {
}
}
/// Returns the token string used for bearer authentication.
pub fn get_token(&self) -> Result<String, std::io::Error> {
match self.mode {
AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => {
match self {
Self::ApiKey(auth) => Ok(auth.api_key.clone()),
Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => {
let access_token = self.get_token_data()?.access_token;
Ok(access_token)
}
}
}
/// Returns `None` if `is_chatgpt_auth()` is false.
pub fn get_account_id(&self) -> Option<String> {
self.get_current_token_data().and_then(|t| t.account_id)
}
/// Returns `None` if `is_chatgpt_auth()` is false.
pub fn get_account_email(&self) -> Option<String> {
self.get_current_token_data().and_then(|t| t.id_token.email)
}
@@ -176,11 +275,18 @@ impl CodexAuth {
})
}
/// Returns `None` if `is_chatgpt_auth()` is false.
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
let state = match self {
Self::ChatGpt(auth) => &auth.state,
Self::ChatGptAuthTokens(auth) => &auth.state,
Self::ApiKey(_) => return None,
};
#[expect(clippy::unwrap_used)]
self.auth_dot_json.lock().unwrap().clone()
state.auth_dot_json.lock().unwrap().clone()
}
/// Returns `None` if `is_chatgpt_auth()` is false.
fn get_current_token_data(&self) -> Option<TokenData> {
self.get_current_auth_json().and_then(|t| t.tokens)
}
@@ -188,7 +294,7 @@ impl CodexAuth {
/// Consider this private to integration tests.
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ChatGPT),
auth_mode: Some(ApiAuthMode::ChatGPT),
openai_api_key: None,
tokens: Some(TokenData {
id_token: Default::default(),
@@ -199,24 +305,19 @@ impl CodexAuth {
last_refresh: Some(Utc::now()),
};
let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
Self {
api_key: None,
mode: AuthMode::ChatGPT,
storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File),
auth_dot_json,
client: crate::default_client::create_client(),
}
let client = crate::default_client::create_client();
let state = ChatGptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client,
};
let storage = create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File);
Self::ChatGpt(ChatGptAuth { state, storage })
}
fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self {
Self {
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File),
auth_dot_json: Arc::new(Mutex::new(None)),
client,
}
fn from_api_key_with_client(api_key: &str, _client: CodexHttpClient) -> Self {
Self::ApiKey(ApiKeyAuth {
api_key: api_key.to_owned(),
})
}
pub fn from_api_key(api_key: &str) -> Self {
@@ -224,6 +325,25 @@ impl CodexAuth {
}
}
impl ChatGptAuth {
fn current_auth_json(&self) -> Option<AuthDotJson> {
#[expect(clippy::unwrap_used)]
self.state.auth_dot_json.lock().unwrap().clone()
}
fn current_token_data(&self) -> Option<TokenData> {
self.current_auth_json().and_then(|auth| auth.tokens)
}
fn storage(&self) -> &Arc<dyn AuthStorageBackend> {
&self.storage
}
fn client(&self) -> &CodexHttpClient {
&self.state.client
}
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
@@ -258,7 +378,7 @@ pub fn login_with_api_key(
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
auth_mode: Some(ApiAuthMode::ApiKey),
openai_api_key: Some(api_key.to_string()),
tokens: None,
last_refresh: None,
@@ -314,10 +434,10 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
};
if let Some(required_method) = config.forced_login_method {
let method_violation = match (required_method, auth.mode) {
let method_violation = match (required_method, auth.internal_auth_mode()) {
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
(ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) => None,
(ForcedLoginMethod::Api, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) => Some(
(ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None,
(ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some(
"API key login is required, but ChatGPT is currently being used. Logging out."
.to_string(),
),
@@ -337,7 +457,7 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
}
if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() {
if !matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) {
if !auth.is_chatgpt_auth() {
return Ok(());
}
@@ -607,7 +727,7 @@ impl AuthDotJson {
};
Self {
auth_mode: Some(AuthMode::ChatgptAuthTokens),
auth_mode: Some(ApiAuthMode::ChatgptAuthTokens),
openai_api_key: None,
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
@@ -623,21 +743,21 @@ impl AuthDotJson {
Ok(Self::from_external_tokens(&external, id_token_info))
}
fn resolved_mode(&self) -> AuthMode {
fn resolved_mode(&self) -> ApiAuthMode {
if let Some(mode) = self.auth_mode {
return mode;
}
if self.openai_api_key.is_some() {
return AuthMode::ApiKey;
return ApiAuthMode::ApiKey;
}
AuthMode::ChatGPT
ApiAuthMode::ChatGPT
}
fn storage_mode(
&self,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> AuthCredentialsStoreMode {
if self.resolved_mode() == AuthMode::ChatgptAuthTokens {
if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens {
AuthCredentialsStoreMode::Ephemeral
} else {
auth_credentials_store_mode
@@ -645,35 +765,6 @@ impl AuthDotJson {
}
}
impl CodexAuth {
fn from_auth_dot_json(
codex_home: &Path,
auth_dot_json: AuthDotJson,
auth_credentials_store_mode: AuthCredentialsStoreMode,
client: CodexHttpClient,
) -> std::io::Result<Self> {
let auth_mode = auth_dot_json.resolved_mode();
if auth_mode == AuthMode::ApiKey {
let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else {
return Err(std::io::Error::other("API key auth is missing a key."));
};
return Ok(CodexAuth::from_api_key_with_client(api_key, client));
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode);
Ok(Self {
api_key: None,
mode: auth_mode,
storage,
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client,
})
}
}
use std::sync::RwLock;
/// Internal cached auth state.
#[derive(Clone)]
struct CachedAuth {
@@ -685,7 +776,10 @@ struct CachedAuth {
impl Debug for CachedAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedAuth")
.field("auth_mode", &self.auth.as_ref().map(|auth| auth.mode))
.field(
"auth_mode",
&self.auth.as_ref().map(CodexAuth::api_auth_mode),
)
.field(
"external_refresher",
&self.external_refresher.as_ref().map(|_| "present"),
@@ -736,11 +830,13 @@ impl UnauthorizedRecovery {
fn new(manager: Arc<AuthManager>) -> Self {
let cached_auth = manager.auth_cached();
let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id);
let mode = match cached_auth {
Some(auth) if auth.mode == AuthMode::ChatgptAuthTokens => {
UnauthorizedRecoveryMode::External
}
_ => UnauthorizedRecoveryMode::Managed,
let mode = if cached_auth
.as_ref()
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
{
UnauthorizedRecoveryMode::External
} else {
UnauthorizedRecoveryMode::Managed
};
let step = match mode {
UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload,
@@ -755,9 +851,12 @@ impl UnauthorizedRecovery {
}
pub fn has_next(&self) -> bool {
if !self.manager.auth_cached().is_some_and(|auth| {
matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens)
}) {
if !self
.manager
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_chatgpt_auth)
{
return false;
}
@@ -996,7 +1095,8 @@ impl AuthManager {
pub fn is_external_auth_active(&self) -> bool {
self.auth_cached()
.is_some_and(|auth| auth.mode == AuthMode::ChatgptAuthTokens)
.as_ref()
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
}
/// Convenience constructor returning an `Arc` wrapper.
@@ -1026,18 +1126,25 @@ impl AuthManager {
Some(auth) => auth,
None => return Ok(()),
};
if auth.mode == AuthMode::ChatgptAuthTokens {
return self
.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
.await;
match auth {
CodexAuth::ChatGptAuthTokens(_) => {
self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
.await
}
CodexAuth::ChatGpt(chatgpt_auth) => {
let token_data = chatgpt_auth.current_token_data().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other(
"Token data is not available.",
))
})?;
self.refresh_tokens(&chatgpt_auth, token_data.refresh_token)
.await?;
// Reload to pick up persisted changes.
self.reload();
Ok(())
}
CodexAuth::ApiKey(_) => Ok(()),
}
let token_data = auth.get_current_token_data().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other("Token data is not available."))
})?;
self.refresh_tokens(&auth, token_data.refresh_token).await?;
// Reload to pick up persisted changes.
self.reload();
Ok(())
}
/// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true)
@@ -1051,16 +1158,23 @@ impl AuthManager {
Ok(removed)
}
pub fn get_auth_mode(&self) -> Option<AuthMode> {
self.auth_cached().map(|a| a.mode)
pub fn get_auth_mode(&self) -> Option<ApiAuthMode> {
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
}
pub fn get_internal_auth_mode(&self) -> Option<AuthMode> {
self.auth_cached()
.as_ref()
.map(CodexAuth::internal_auth_mode)
}
async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result<bool, RefreshTokenError> {
if auth.mode != AuthMode::ChatGPT {
return Ok(false);
}
let chatgpt_auth = match auth {
CodexAuth::ChatGpt(chatgpt_auth) => chatgpt_auth,
_ => return Ok(false),
};
let auth_dot_json = match auth.get_current_auth_json() {
let auth_dot_json = match chatgpt_auth.current_auth_json() {
Some(auth_dot_json) => auth_dot_json,
None => return Ok(false),
};
@@ -1075,7 +1189,8 @@ impl AuthManager {
if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) {
return Ok(false);
}
self.refresh_tokens(auth, tokens.refresh_token).await?;
self.refresh_tokens(chatgpt_auth, tokens.refresh_token)
.await?;
self.reload();
Ok(true)
}
@@ -1135,13 +1250,13 @@ impl AuthManager {
async fn refresh_tokens(
&self,
auth: &CodexAuth,
auth: &ChatGptAuth,
refresh_token: String,
) -> Result<(), RefreshTokenError> {
let refresh_response = try_refresh_token(refresh_token, &auth.client).await?;
let refresh_response = try_refresh_token(refresh_token, auth.client()).await?;
update_tokens(
&auth.storage,
auth.storage(),
refresh_response.id_token,
refresh_response.access_token,
refresh_response.refresh_token,
@@ -1254,26 +1369,21 @@ mod tests {
)
.expect("failed to write auth file");
let CodexAuth {
api_key,
mode,
auth_dot_json,
storage: _,
..
} = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(None, api_key);
assert_eq!(AuthMode::ChatGPT, mode);
assert_eq!(None, auth.api_key());
assert_eq!(AuthMode::ChatGPT, auth.internal_auth_mode());
let guard = auth_dot_json.lock().unwrap();
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
let auth_dot_json = auth
.get_current_auth_json()
.expect("AuthDotJson should exist");
let last_refresh = auth_dot_json
.last_refresh
.expect("last_refresh should be recorded");
assert_eq!(
&AuthDotJson {
AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: Some(TokenData {
@@ -1308,8 +1418,8 @@ mod tests {
let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey);
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
assert_eq!(auth.internal_auth_mode(), AuthMode::ApiKey);
assert_eq!(auth.api_key(), Some("sk-test-key"));
assert!(auth.get_token_data().is_err());
}
@@ -1318,7 +1428,7 @@ mod tests {
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
auth_mode: Some(ApiAuthMode::ApiKey),
openai_api_key: Some("sk-test-key".to_string()),
tokens: None,
last_refresh: None,
+6 -8
View File
@@ -27,7 +27,6 @@ use codex_api::common::ResponsesWsRequest;
use codex_api::create_text_param_for_request;
use codex_api::error::ApiError;
use codex_api::requests::responses::Compression;
use codex_app_server_protocol::AuthMode;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
@@ -50,6 +49,7 @@ use tokio::sync::mpsc;
use tracing::warn;
use crate::AuthManager;
use crate::auth::CodexAuth;
use crate::auth::RefreshTokenError;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
@@ -220,7 +220,7 @@ impl ModelClient {
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = self.build_request_telemetry();
@@ -469,9 +469,7 @@ impl ModelClientSession {
.config
.features
.enabled(Feature::EnableRequestCompression)
&& auth.is_some_and(|auth| {
matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens)
})
&& auth.is_some_and(CodexAuth::is_chatgpt_auth)
&& self.state.provider.is_openai()
{
Compression::Zstd
@@ -509,7 +507,7 @@ impl ModelClientSession {
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
@@ -565,7 +563,7 @@ impl ModelClientSession {
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
@@ -611,7 +609,7 @@ impl ModelClientSession {
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let compression = self.responses_request_compression(auth.as_ref());
+1 -1
View File
@@ -907,7 +907,7 @@ impl Session {
session_configuration.collaboration_mode.model(),
auth.and_then(CodexAuth::get_account_id),
auth.and_then(CodexAuth::get_account_email),
auth.map(|a| a.mode),
auth.map(CodexAuth::api_auth_mode),
config.otel.log_user_prompt,
terminal::user_agent(),
session_configuration.session_source.clone(),
+10 -2
View File
@@ -468,7 +468,11 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::RequestRule,
key: "request_rule",
stage: Stage::UnderDevelopment,
stage: Stage::Experimental {
name: "Smart approvals",
menu_description: "Get smarter \"Don't ask again\" rule requests.",
announcement: "NEW: Try Smart approvals to get smarter \"Don't ask again\" requests. Enable in /experimental!",
},
default_enabled: false,
},
FeatureSpec {
@@ -584,7 +588,11 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::Personality,
key: "personality",
stage: Stage::UnderDevelopment,
stage: Stage::Experimental {
name: "Personality",
menu_description: "Choose a communication style for Codex.",
announcement: "NEW: Pick a personality for Codex. Enable in /experimental!",
},
default_enabled: false,
},
FeatureSpec {
+3 -6
View File
@@ -5,10 +5,11 @@
//! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers`
//! key. These override or extend the defaults at runtime.
use crate::auth::AuthMode;
use crate::error::EnvVarError;
use codex_api::Provider as ApiProvider;
use codex_api::WireApi as ApiWireApi;
use codex_api::provider::RetryConfig as ApiRetryConfig;
use codex_app_server_protocol::AuthMode;
use http::HeaderMap;
use http::header::HeaderName;
use http::header::HeaderValue;
@@ -19,7 +20,6 @@ use std::collections::HashMap;
use std::env::VarError;
use std::time::Duration;
use crate::error::EnvVarError;
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
@@ -137,10 +137,7 @@ impl ModelProviderInfo {
&self,
auth_mode: Option<AuthMode>,
) -> crate::error::Result<ApiProvider> {
let default_base_url = if matches!(
auth_mode,
Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens)
) {
let default_base_url = if matches!(auth_mode, Some(AuthMode::ChatGPT)) {
"https://chatgpt.com/backend-api/codex"
} else {
"https://api.openai.com/v1"
+7 -7
View File
@@ -2,6 +2,7 @@ use super::cache::ModelsCacheManager;
use crate::api_bridge::auth_provider_from_auth;
use crate::api_bridge::map_api_error;
use crate::auth::AuthManager;
use crate::auth::AuthMode;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use crate::error::CodexErr;
@@ -13,7 +14,6 @@ use crate::models_manager::model_info;
use crate::models_manager::model_presets::builtin_model_presets;
use codex_api::ModelsClient;
use codex_api::ReqwestTransport;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
@@ -61,7 +61,7 @@ impl ModelsManager {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
local_models: builtin_model_presets(auth_manager.get_internal_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
@@ -175,7 +175,7 @@ impl ModelsManager {
refresh_strategy: RefreshStrategy,
) -> CoreResult<()> {
if !config.features.enabled(Feature::RemoteModels)
|| self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey)
|| self.auth_manager.get_internal_auth_mode() == Some(AuthMode::ApiKey)
{
return Ok(());
}
@@ -204,7 +204,7 @@ impl ModelsManager {
let _timer =
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
let auth = self.auth_manager.auth().await;
let auth_mode = self.auth_manager.get_auth_mode();
let auth_mode = self.auth_manager.get_internal_auth_mode();
let api_provider = self.provider.to_api_provider(auth_mode)?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
@@ -273,8 +273,8 @@ impl ModelsManager {
let existing_presets = self.local_models.clone();
let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets);
let chatgpt_mode = matches!(
self.auth_manager.get_auth_mode(),
Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens)
self.auth_manager.get_internal_auth_mode(),
Some(AuthMode::ChatGPT)
);
merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode);
@@ -319,7 +319,7 @@ impl ModelsManager {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
local_models: builtin_model_presets(auth_manager.get_internal_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
@@ -1,4 +1,4 @@
use codex_app_server_protocol::AuthMode;
use crate::auth::AuthMode;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort;
+2 -2
View File
@@ -428,13 +428,13 @@ impl RolloutRecorder {
}
},
Err(e) => {
warn!("failed to parse rollout line: {v:?}, error: {e}");
warn!("failed to parse rollout line: {e}");
parse_errors = parse_errors.saturating_add(1);
}
}
}
info!(
tracing::debug!(
"Resumed rollout with {} items, thread ID: {:?}, parse errors: {}",
items.len(),
thread_id,
-6
View File
@@ -33,12 +33,6 @@ pub(crate) async fn init_if_enabled(
) -> Option<StateDbHandle> {
let state_path = config.codex_home.join(STATE_DB_FILENAME);
if !config.features.enabled(Feature::Sqlite) {
// We delete the file on best effort basis to maintain retro-compatibility in the future.
let wal_path = state_path.with_extension("sqlite-wal");
let shm_path = state_path.with_extension("sqlite-shm");
for path in [state_path.as_path(), wal_path.as_path(), shm_path.as_path()] {
tokio::fs::remove_file(path).await.ok();
}
return None;
}
let existed = tokio::fs::try_exists(&state_path).await.unwrap_or(false);
+102 -42
View File
@@ -3,8 +3,6 @@ use std::time::Duration;
use anyhow::Context;
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use clap::Parser;
use codex_state::LogQuery;
use codex_state::LogRow;
@@ -37,17 +35,21 @@ struct Args {
#[arg(long, value_name = "RFC3339|UNIX")]
to: Option<String>,
/// Substring match on module_path.
#[arg(long)]
module: Option<String>,
/// Substring match on module_path. Repeat to include multiple substrings.
#[arg(long = "module")]
module: Vec<String>,
/// Substring match on file path.
#[arg(long)]
file: Option<String>,
/// Substring match on file path. Repeat to include multiple substrings.
#[arg(long = "file")]
file: Vec<String>,
/// Match a specific thread id.
/// Match one or more thread ids. Repeat to include multiple threads.
#[arg(long = "thread-id")]
thread_id: Vec<String>,
/// Include logs that do not have a thread id.
#[arg(long)]
thread_id: Option<String>,
threadless: bool,
/// Number of matching rows to show before tailing.
#[arg(long, default_value_t = 200)]
@@ -63,9 +65,10 @@ struct LogFilter {
level_upper: Option<String>,
from_ts: Option<i64>,
to_ts: Option<i64>,
module_like: Option<String>,
file_like: Option<String>,
thread_id: Option<String>,
module_like: Vec<String>,
file_like: Vec<String>,
thread_ids: Vec<String>,
include_threadless: bool,
}
#[tokio::main]
@@ -126,14 +129,33 @@ fn build_filter(args: &Args) -> anyhow::Result<LogFilter> {
.context("failed to parse --to")?;
let level_upper = args.level.as_ref().map(|level| level.to_ascii_uppercase());
let module_like = args
.module
.iter()
.filter(|module| !module.is_empty())
.cloned()
.collect::<Vec<_>>();
let file_like = args
.file
.iter()
.filter(|file| !file.is_empty())
.cloned()
.collect::<Vec<_>>();
let thread_ids = args
.thread_id
.iter()
.filter(|thread_id| !thread_id.is_empty())
.cloned()
.collect::<Vec<_>>();
Ok(LogFilter {
level_upper,
from_ts,
to_ts,
module_like: args.module.clone(),
file_like: args.file.clone(),
thread_id: args.thread_id.clone(),
module_like,
file_like,
thread_ids,
include_threadless: args.threadless,
})
}
@@ -211,7 +233,8 @@ fn to_log_query(
to_ts: filter.to_ts,
module_like: filter.module_like.clone(),
file_like: filter.file_like.clone(),
thread_id: filter.thread_id.clone(),
thread_ids: filter.thread_ids.clone(),
include_threadless: filter.include_threadless,
after_id,
limit,
descending,
@@ -219,45 +242,82 @@ fn to_log_query(
}
fn format_row(row: &LogRow) -> String {
let timestamp = format_timestamp(row.ts, row.ts_nanos);
let timestamp = formatter::ts(row.ts, row.ts_nanos);
let level = row.level.as_str();
let target = row.target.as_str();
let message = row.message.as_deref().unwrap_or("");
let level_colored = color_level(level);
let level_colored = formatter::level(level);
let timestamp_colored = timestamp.dimmed().to_string();
let thread_id = row.thread_id.as_deref().unwrap_or("-");
let thread_id_colored = thread_id.blue().dimmed().to_string();
let target_colored = target.dimmed().to_string();
let message_colored = message.bold().to_string();
let message_colored = heuristic_formatting(message);
format!(
"{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}"
)
}
fn color_level(level: &str) -> String {
let padded = format!("{level:<5}");
if level.eq_ignore_ascii_case("error") {
return padded.red().bold().to_string();
fn heuristic_formatting(message: &str) -> String {
if matcher::apply_patch(message) {
formatter::apply_patch(message)
} else {
message.bold().to_string()
}
if level.eq_ignore_ascii_case("warn") {
return padded.yellow().bold().to_string();
}
if level.eq_ignore_ascii_case("info") {
return padded.green().bold().to_string();
}
if level.eq_ignore_ascii_case("debug") {
return padded.blue().bold().to_string();
}
if level.eq_ignore_ascii_case("trace") {
return padded.magenta().bold().to_string();
}
padded.bold().to_string()
}
fn format_timestamp(ts: i64, ts_nanos: i64) -> String {
let nanos = u32::try_from(ts_nanos).unwrap_or(0);
match DateTime::<Utc>::from_timestamp(ts, nanos) {
Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
None => format!("{ts}.{ts_nanos:09}Z"),
mod matcher {
pub(super) fn apply_patch(message: &str) -> bool {
message.starts_with("ToolCall: apply_patch")
}
}
mod formatter {
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use owo_colors::OwoColorize;
pub(super) fn apply_patch(message: &str) -> String {
message
.lines()
.map(|line| {
if line.starts_with('+') {
line.green().bold().to_string()
} else if line.starts_with('-') {
line.red().bold().to_string()
} else {
line.bold().to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn ts(ts: i64, ts_nanos: i64) -> String {
let nanos = u32::try_from(ts_nanos).unwrap_or(0);
match DateTime::<Utc>::from_timestamp(ts, nanos) {
Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
None => format!("{ts}.{ts_nanos:09}Z"),
}
}
pub(super) fn level(level: &str) -> String {
let padded = format!("{level:<5}");
if level.eq_ignore_ascii_case("error") {
return padded.red().bold().to_string();
}
if level.eq_ignore_ascii_case("warn") {
return padded.yellow().bold().to_string();
}
if level.eq_ignore_ascii_case("info") {
return padded.green().bold().to_string();
}
if level.eq_ignore_ascii_case("debug") {
return padded.blue().bold().to_string();
}
if level.eq_ignore_ascii_case("trace") {
return padded.magenta().bold().to_string();
}
padded.bold().to_string()
}
}
+5
View File
@@ -30,6 +30,11 @@ pub fn apply_rollout_item(
}
fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &SessionMetaLine) {
if metadata.id != meta_line.meta.id {
// Ignore session_meta lines that don't match the canonical thread ID,
// e.g., forked rollouts that embed the source session metadata.
return;
}
metadata.id = meta_line.meta.id;
metadata.source = enum_to_string(&meta_line.meta.source);
if let Some(provider) = meta_line.meta.model_provider.as_deref() {
+4 -3
View File
@@ -32,9 +32,10 @@ pub struct LogQuery {
pub level_upper: Option<String>,
pub from_ts: Option<i64>,
pub to_ts: Option<i64>,
pub module_like: Option<String>,
pub file_like: Option<String>,
pub thread_id: Option<String>,
pub module_like: Vec<String>,
pub file_like: Vec<String>,
pub thread_ids: Vec<String>,
pub include_threadless: bool,
pub after_id: Option<i64>,
pub limit: Option<usize>,
pub descending: bool,
+42 -16
View File
@@ -457,28 +457,54 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu
if let Some(to_ts) = query.to_ts {
builder.push(" AND ts <= ").push_bind(to_ts);
}
if let Some(module_like) = query.module_like.as_ref() {
builder
.push(" AND module_path LIKE '%' || ")
.push_bind(module_like.as_str())
.push(" || '%'");
}
if let Some(file_like) = query.file_like.as_ref() {
builder
.push(" AND file LIKE '%' || ")
.push_bind(file_like.as_str())
.push(" || '%'");
}
if let Some(thread_id) = query.thread_id.as_ref() {
builder
.push(" AND thread_id = ")
.push_bind(thread_id.as_str());
push_like_filters(builder, "module_path", &query.module_like);
push_like_filters(builder, "file", &query.file_like);
let has_thread_filter = !query.thread_ids.is_empty() || query.include_threadless;
if has_thread_filter {
builder.push(" AND (");
let mut needs_or = false;
for thread_id in &query.thread_ids {
if needs_or {
builder.push(" OR ");
}
builder.push("thread_id = ").push_bind(thread_id.as_str());
needs_or = true;
}
if query.include_threadless {
if needs_or {
builder.push(" OR ");
}
builder.push("thread_id IS NULL");
}
builder.push(")");
}
if let Some(after_id) = query.after_id {
builder.push(" AND id > ").push_bind(after_id);
}
}
fn push_like_filters<'a>(
builder: &mut QueryBuilder<'a, Sqlite>,
column: &str,
filters: &'a [String],
) {
if filters.is_empty() {
return;
}
builder.push(" AND (");
for (idx, filter) in filters.iter().enumerate() {
if idx > 0 {
builder.push(" OR ");
}
builder
.push(column)
.push(" LIKE '%' || ")
.push_bind(filter.as_str())
.push(" || '%'");
}
builder.push(")");
}
async fn open_sqlite(path: &Path) -> anyhow::Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(path)
+1 -1
View File
@@ -984,7 +984,7 @@ impl App {
model.as_str(),
auth_ref.and_then(CodexAuth::get_account_id),
auth_ref.and_then(CodexAuth::get_account_email),
auth_ref.map(|auth| auth.mode),
auth_ref.map(CodexAuth::api_auth_mode),
config.otel.log_user_prompt,
codex_core::terminal::user_agent(),
SessionSource::Cli,
+7 -6
View File
@@ -30,7 +30,6 @@ use std::time::Duration;
use std::time::Instant;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AuthMode;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_core::config::Config;
@@ -3584,10 +3583,12 @@ impl ChatWidget {
fn prefetch_rate_limits(&mut self) {
self.stop_rate_limit_poller();
if !matches!(
self.auth_manager.auth_cached().map(|auth| auth.mode),
Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens)
) {
if !self
.auth_manager
.auth_cached()
.as_ref()
.is_some_and(CodexAuth::is_chatgpt_auth)
{
return;
}
@@ -3600,7 +3601,7 @@ impl ChatWidget {
loop {
if let Some(auth) = auth_manager.auth().await
&& matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens)
&& auth.is_chatgpt_auth()
&& let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await
{
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
+1 -1
View File
@@ -833,7 +833,7 @@ fn get_login_status(config: &Config) -> LoginStatus {
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
Ok(Some(auth)) => LoginStatus::AuthMode(auth.api_auth_mode()),
Ok(None) => LoginStatus::NotAuthenticated,
Err(err) => {
error!("Failed to read auth.json: {err}");
+4 -4
View File
@@ -2,8 +2,8 @@ use crate::exec_command::relativize_to_home;
use crate::text_formatting;
use chrono::DateTime;
use chrono::Local;
use codex_app_server_protocol::AuthMode;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config::Config;
use codex_core::project_doc::discover_project_doc_paths;
use codex_protocol::account::PlanType;
@@ -90,15 +90,15 @@ pub(crate) fn compose_account_display(
) -> Option<StatusAccountDisplay> {
let auth = auth_manager.auth_cached()?;
match auth.mode {
AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => {
match auth {
CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => {
let email = auth.get_account_email();
let plan = plan
.map(|plan_type| title_case(format!("{plan_type:?}").as_str()))
.or_else(|| Some("Unknown".to_string()));
Some(StatusAccountDisplay::ChatGpt { email, plan })
}
AuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey),
CodexAuth::ApiKey(_) => Some(StatusAccountDisplay::ApiKey),
}
}