Skip to content

Commit ca2ad71

Browse files
ccy-oaicodex
andcommitted
[Codex][Codex CLI] Add auth observability signals
Add client-visible auth observability for 401 recovery, endpoint classification, and geo-denial diagnosis without changing auth behavior. Co-authored-by: Codex <noreply@openai.com>
1 parent 4e99c0f commit ca2ad71

27 files changed

+3198
-45
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/cloud-requirements/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ impl CloudRequirementsService {
391391
"Cloud requirements request was unauthorized; attempting auth recovery"
392392
);
393393
match auth_recovery.next().await {
394-
Ok(()) => {
394+
Ok(_) => {
395395
let Some(refreshed_auth) = self.auth_manager.auth().await else {
396396
tracing::error!(
397397
"Auth recovery succeeded but no auth is available for cloud requirements"

codex-rs/codex-api/src/endpoint/responses_websocket.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ impl ResponsesWebsocketConnection {
203203
pub async fn stream_request(
204204
&self,
205205
request: ResponsesWsRequest,
206+
connection_reused: bool,
206207
) -> Result<ResponseStream, ApiError> {
207208
let (tx_event, rx_event) =
208209
mpsc::channel::<std::result::Result<ResponseEvent, ApiError>>(1600);
@@ -245,6 +246,7 @@ impl ResponsesWebsocketConnection {
245246
request_body,
246247
idle_timeout,
247248
telemetry,
249+
connection_reused,
248250
)
249251
.await
250252
};
@@ -505,6 +507,7 @@ async fn run_websocket_response_stream(
505507
request_body: Value,
506508
idle_timeout: Duration,
507509
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
510+
connection_reused: bool,
508511
) -> Result<(), ApiError> {
509512
let mut last_server_model: Option<String> = None;
510513
let request_text = match serde_json::to_string(&request_body) {
@@ -524,7 +527,11 @@ async fn run_websocket_response_stream(
524527
.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")));
525528

526529
if let Some(t) = telemetry.as_ref() {
527-
t.on_ws_request(request_start.elapsed(), result.as_ref().err());
530+
t.on_ws_request(
531+
request_start.elapsed(),
532+
result.as_ref().err(),
533+
connection_reused,
534+
);
528535
}
529536

530537
result?;

codex-rs/codex-api/src/telemetry.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ pub trait SseTelemetry: Send + Sync {
3333

3434
/// Telemetry for Responses WebSocket transport.
3535
pub trait WebsocketTelemetry: Send + Sync {
36-
fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>);
36+
fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>, connection_reused: bool);
3737

3838
fn on_ws_event(
3939
&self,

codex-rs/core/src/api_bridge.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use base64::Engine;
12
use chrono::DateTime;
23
use chrono::Utc;
34
use codex_api::AuthProvider as ApiAuthProvider;
@@ -7,6 +8,7 @@ use codex_api::rate_limits::parse_promo_message;
78
use codex_api::rate_limits::parse_rate_limit_for_limit;
89
use http::HeaderMap;
910
use serde::Deserialize;
11+
use serde_json::Value;
1012

1113
use crate::auth::CodexAuth;
1214
use crate::error::CodexErr;
@@ -30,6 +32,8 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
3032
url: None,
3133
cf_ray: None,
3234
request_id: None,
35+
identity_authorization_error: None,
36+
identity_error_code: None,
3337
}),
3438
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
3539
ApiError::Transport(transport) => match transport {
@@ -98,6 +102,11 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
98102
url,
99103
cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER),
100104
request_id: extract_request_id(headers.as_ref()),
105+
identity_authorization_error: extract_header(
106+
headers.as_ref(),
107+
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
108+
),
109+
identity_error_code: extract_x_error_json_code(headers.as_ref()),
101110
})
102111
}
103112
}
@@ -118,6 +127,8 @@ const ACTIVE_LIMIT_HEADER: &str = "x-codex-active-limit";
118127
const REQUEST_ID_HEADER: &str = "x-request-id";
119128
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
120129
const CF_RAY_HEADER: &str = "cf-ray";
130+
const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error";
131+
const X_ERROR_JSON_HEADER: &str = "x-error-json";
121132

122133
#[cfg(test)]
123134
#[path = "api_bridge_tests.rs"]
@@ -140,6 +151,19 @@ fn extract_header(headers: Option<&HeaderMap>, name: &str) -> Option<String> {
140151
})
141152
}
142153

154+
fn extract_x_error_json_code(headers: Option<&HeaderMap>) -> Option<String> {
155+
let encoded = extract_header(headers, X_ERROR_JSON_HEADER)?;
156+
let decoded = base64::engine::general_purpose::STANDARD
157+
.decode(encoded)
158+
.ok()?;
159+
let parsed = serde_json::from_slice::<Value>(&decoded).ok()?;
160+
parsed
161+
.get("error")
162+
.and_then(|error| error.get("code"))
163+
.and_then(Value::as_str)
164+
.map(str::to_string)
165+
}
166+
143167
pub(crate) fn auth_provider_from_auth(
144168
auth: Option<CodexAuth>,
145169
provider: &ModelProviderInfo,
@@ -191,6 +215,26 @@ pub(crate) struct CoreAuthProvider {
191215
account_id: Option<String>,
192216
}
193217

218+
impl CoreAuthProvider {
219+
pub(crate) fn auth_header_attached(&self) -> bool {
220+
self.token
221+
.as_ref()
222+
.is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok())
223+
}
224+
225+
pub(crate) fn auth_header_name(&self) -> Option<&'static str> {
226+
self.auth_header_attached().then_some("authorization")
227+
}
228+
229+
#[cfg(test)]
230+
pub(crate) fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self {
231+
Self {
232+
token: token.map(str::to_string),
233+
account_id: account_id.map(str::to_string),
234+
}
235+
}
236+
}
237+
194238
impl ApiAuthProvider for CoreAuthProvider {
195239
fn bearer_token(&self) -> Option<String> {
196240
self.token.clone()

codex-rs/core/src/api_bridge_tests.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use super::*;
2+
use base64::Engine;
23
use pretty_assertions::assert_eq;
34

45
#[test]
@@ -94,3 +95,49 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
9495
None
9596
);
9697
}
98+
99+
#[test]
100+
fn map_api_error_extracts_identity_auth_details_from_headers() {
101+
let mut headers = HeaderMap::new();
102+
headers.insert(REQUEST_ID_HEADER, http::HeaderValue::from_static("req-401"));
103+
headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-401"));
104+
headers.insert(
105+
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
106+
http::HeaderValue::from_static("missing_authorization_header"),
107+
);
108+
let x_error_json =
109+
base64::engine::general_purpose::STANDARD.encode(r#"{"error":{"code":"token_expired"}}"#);
110+
headers.insert(
111+
X_ERROR_JSON_HEADER,
112+
http::HeaderValue::from_str(&x_error_json).expect("valid x-error-json header"),
113+
);
114+
115+
let err = map_api_error(ApiError::Transport(TransportError::Http {
116+
status: http::StatusCode::UNAUTHORIZED,
117+
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
118+
headers: Some(headers),
119+
body: Some(r#"{"detail":"Unauthorized"}"#.to_string()),
120+
}));
121+
122+
let CodexErr::UnexpectedStatus(err) = err else {
123+
panic!("expected CodexErr::UnexpectedStatus, got {err:?}");
124+
};
125+
assert_eq!(err.request_id.as_deref(), Some("req-401"));
126+
assert_eq!(err.cf_ray.as_deref(), Some("ray-401"));
127+
assert_eq!(
128+
err.identity_authorization_error.as_deref(),
129+
Some("missing_authorization_header")
130+
);
131+
assert_eq!(err.identity_error_code.as_deref(), Some("token_expired"));
132+
}
133+
134+
#[test]
135+
fn core_auth_provider_reports_when_auth_header_will_attach() {
136+
let auth = CoreAuthProvider {
137+
token: Some("access-token".to_string()),
138+
account_id: None,
139+
};
140+
141+
assert!(auth.auth_header_attached());
142+
assert_eq!(auth.auth_header_name(), Some("authorization"));
143+
}

codex-rs/core/src/auth.rs

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,17 @@ pub struct UnauthorizedRecovery {
874874
mode: UnauthorizedRecoveryMode,
875875
}
876876

877+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
878+
pub struct UnauthorizedRecoveryStepResult {
879+
auth_state_changed: Option<bool>,
880+
}
881+
882+
impl UnauthorizedRecoveryStepResult {
883+
pub fn auth_state_changed(&self) -> Option<bool> {
884+
self.auth_state_changed
885+
}
886+
}
887+
877888
impl UnauthorizedRecovery {
878889
fn new(manager: Arc<AuthManager>) -> Self {
879890
let cached_auth = manager.auth_cached();
@@ -917,7 +928,46 @@ impl UnauthorizedRecovery {
917928
!matches!(self.step, UnauthorizedRecoveryStep::Done)
918929
}
919930

920-
pub async fn next(&mut self) -> Result<(), RefreshTokenError> {
931+
pub fn unavailable_reason(&self) -> &'static str {
932+
if !self
933+
.manager
934+
.auth_cached()
935+
.as_ref()
936+
.is_some_and(CodexAuth::is_chatgpt_auth)
937+
{
938+
return "not_chatgpt_auth";
939+
}
940+
941+
if self.mode == UnauthorizedRecoveryMode::External
942+
&& !self.manager.has_external_auth_refresher()
943+
{
944+
return "no_external_refresher";
945+
}
946+
947+
if matches!(self.step, UnauthorizedRecoveryStep::Done) {
948+
return "recovery_exhausted";
949+
}
950+
951+
"ready"
952+
}
953+
954+
pub fn mode_name(&self) -> &'static str {
955+
match self.mode {
956+
UnauthorizedRecoveryMode::Managed => "managed",
957+
UnauthorizedRecoveryMode::External => "external",
958+
}
959+
}
960+
961+
pub fn step_name(&self) -> &'static str {
962+
match self.step {
963+
UnauthorizedRecoveryStep::Reload => "reload",
964+
UnauthorizedRecoveryStep::RefreshToken => "refresh_token",
965+
UnauthorizedRecoveryStep::ExternalRefresh => "external_refresh",
966+
UnauthorizedRecoveryStep::Done => "done",
967+
}
968+
}
969+
970+
pub async fn next(&mut self) -> Result<UnauthorizedRecoveryStepResult, RefreshTokenError> {
921971
if !self.has_next() {
922972
return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
923973
RefreshTokenFailedReason::Other,
@@ -931,8 +981,17 @@ impl UnauthorizedRecovery {
931981
.manager
932982
.reload_if_account_id_matches(self.expected_account_id.as_deref())
933983
{
934-
ReloadOutcome::ReloadedChanged | ReloadOutcome::ReloadedNoChange => {
984+
ReloadOutcome::ReloadedChanged => {
935985
self.step = UnauthorizedRecoveryStep::RefreshToken;
986+
return Ok(UnauthorizedRecoveryStepResult {
987+
auth_state_changed: Some(true),
988+
});
989+
}
990+
ReloadOutcome::ReloadedNoChange => {
991+
self.step = UnauthorizedRecoveryStep::RefreshToken;
992+
return Ok(UnauthorizedRecoveryStepResult {
993+
auth_state_changed: Some(false),
994+
});
936995
}
937996
ReloadOutcome::Skipped => {
938997
self.step = UnauthorizedRecoveryStep::Done;
@@ -946,16 +1005,24 @@ impl UnauthorizedRecovery {
9461005
UnauthorizedRecoveryStep::RefreshToken => {
9471006
self.manager.refresh_token_from_authority().await?;
9481007
self.step = UnauthorizedRecoveryStep::Done;
1008+
return Ok(UnauthorizedRecoveryStepResult {
1009+
auth_state_changed: Some(true),
1010+
});
9491011
}
9501012
UnauthorizedRecoveryStep::ExternalRefresh => {
9511013
self.manager
9521014
.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
9531015
.await?;
9541016
self.step = UnauthorizedRecoveryStep::Done;
1017+
return Ok(UnauthorizedRecoveryStepResult {
1018+
auth_state_changed: Some(true),
1019+
});
9551020
}
9561021
UnauthorizedRecoveryStep::Done => {}
9571022
}
958-
Ok(())
1023+
Ok(UnauthorizedRecoveryStepResult {
1024+
auth_state_changed: None,
1025+
})
9591026
}
9601027
}
9611028

codex-rs/core/src/auth_tests.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use codex_protocol::config_types::ForcedLoginMethod;
1313
use pretty_assertions::assert_eq;
1414
use serde::Serialize;
1515
use serde_json::json;
16+
use std::sync::Arc;
1617
use tempfile::tempdir;
1718

1819
#[tokio::test]
@@ -171,6 +172,33 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> {
171172
Ok(())
172173
}
173174

175+
#[test]
176+
fn unauthorized_recovery_reports_mode_and_step_names() {
177+
let dir = tempdir().unwrap();
178+
let manager = AuthManager::shared(
179+
dir.path().to_path_buf(),
180+
false,
181+
AuthCredentialsStoreMode::File,
182+
);
183+
let managed = UnauthorizedRecovery {
184+
manager: Arc::clone(&manager),
185+
step: UnauthorizedRecoveryStep::Reload,
186+
expected_account_id: None,
187+
mode: UnauthorizedRecoveryMode::Managed,
188+
};
189+
assert_eq!(managed.mode_name(), "managed");
190+
assert_eq!(managed.step_name(), "reload");
191+
192+
let external = UnauthorizedRecovery {
193+
manager,
194+
step: UnauthorizedRecoveryStep::ExternalRefresh,
195+
expected_account_id: None,
196+
mode: UnauthorizedRecoveryMode::External,
197+
};
198+
assert_eq!(external.mode_name(), "external");
199+
assert_eq!(external.step_name(), "external_refresh");
200+
}
201+
174202
struct AuthFileParams {
175203
openai_api_key: Option<String>,
176204
chatgpt_plan_type: Option<String>,

0 commit comments

Comments
 (0)