Compare commits

...

1 Commits

Author SHA1 Message Date
Rahul Thathoo
15536f15e9 Improve API key auth failure message 2026-05-17 20:54:19 -07:00
3 changed files with 178 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::format_with_current_shell_display;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use app_test_support::write_models_cache;
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
@@ -36,6 +37,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::TextElement;
@@ -53,10 +55,12 @@ use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::WarningNotification;
use codex_config::config_toml::ConfigToml;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME;
use codex_core::test_support::all_model_presets;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_login::login_with_api_key;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
@@ -76,6 +80,10 @@ use std::collections::HashMap;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
use super::analytics::mount_analytics_capture;
use super::analytics::wait_for_analytics_event;
@@ -87,6 +95,8 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
const TEST_ORIGINATOR: &str = "codex_vscode";
const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer.";
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const INVALID_API_KEY_MESSAGE: &str =
"The configured API key is invalid or unusable. Update your API key and try again.";
const TINY_PNG_BYTES: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1,
@@ -99,6 +109,97 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool {
.is_some_and(|body| body.contains(text))
}
#[tokio::test]
async fn turn_start_with_invalid_api_key_auth_surfaces_recredential_message() -> Result<()> {
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path_regex(".*/responses$"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": { "message": "Incorrect API key provided" }
})))
.expect(1)
.mount(&server)
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::new(),
/*auto_compact_limit*/ 1024,
/*requires_openai_auth*/ Some(true),
"mock_provider",
"compact",
)?;
login_with_api_key(
codex_home.path(),
"sk-invalid-key",
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let error: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("error"),
)
.await??;
let parsed: ServerNotification = error.try_into()?;
let ServerNotification::Error(error) = parsed else {
anyhow::bail!("unexpected notification variant");
};
assert_eq!(error.error.message, INVALID_API_KEY_MESSAGE);
let completed: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed: TurnCompletedNotification = serde_json::from_value(
completed
.params
.expect("turn/completed params should be present"),
)?;
assert_eq!(
completed
.turn
.error
.as_ref()
.map(|error| error.message.as_str()),
Some(INVALID_API_KEY_MESSAGE)
);
server.verify().await;
Ok(())
}
async fn run_local_image_turn(detail: Option<ImageDetail>) -> Result<Vec<Value>> {
// Two Codex turns hit the mock model (session start + turn/start).
let responses = vec![

View File

@@ -147,6 +147,8 @@ const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=20
const RESPONSES_ENDPOINT: &str = "/responses";
const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact";
const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize";
const INVALID_API_KEY_AUTH_MESSAGE: &str =
"The configured API key is invalid or unusable. Update your API key and try again.";
#[cfg(test)]
pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS);
@@ -190,6 +192,24 @@ struct CurrentClientSetup {
api_auth: SharedAuthProvider,
}
fn map_api_error_for_auth(err: ApiError, auth: Option<&CodexAuth>) -> CodexErr {
map_api_key_auth_failure(map_api_error(err), auth.map(CodexAuth::auth_mode))
}
fn map_api_key_auth_failure(err: CodexErr, auth_mode: Option<AuthMode>) -> CodexErr {
if auth_mode == Some(AuthMode::ApiKey)
&& matches!(
err,
CodexErr::UnexpectedStatus(ref response)
if matches!(response.status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
)
{
CodexErr::InvalidRequest(INVALID_API_KEY_AUTH_MESSAGE.to_string())
} else {
err
}
}
#[derive(Clone, Copy)]
struct RequestRouteTelemetry {
endpoint: &'static str,
@@ -509,7 +529,7 @@ impl ModelClient {
let result = client
.compact_input(&payload, extra_headers)
.await
.map_err(map_api_error);
.map_err(|err| map_api_error_for_auth(err, client_setup.auth.as_ref()));
trace_attempt.record_result(result.as_deref());
result
}
@@ -535,7 +555,7 @@ impl ModelClient {
ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.create_with_session_and_headers(sdp, session_config, extra_headers)
.await
.map_err(map_api_error)?;
.map_err(|err| map_api_error_for_auth(err, client_setup.auth.as_ref()))?;
Ok(RealtimeWebrtcCallStart {
sdp: response.sdp,
call_id: response.call_id,
@@ -588,7 +608,7 @@ impl ModelClient {
client
.summarize_input(&payload, self.build_subagent_headers())
.await
.map_err(map_api_error)
.map_err(|err| map_api_error_for_auth(err, client_setup.auth.as_ref()))
}
fn build_subagent_headers(&self) -> ApiHeaderMap {
@@ -1285,6 +1305,7 @@ impl ModelClientSession {
unauthorized_transport,
&mut auth_recovery,
session_telemetry,
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
)
.await?,
);
@@ -1293,7 +1314,7 @@ impl ModelClientSession {
Err(err) => {
let response_debug_context =
extract_response_debug_context_from_api_error(&err);
let err = map_api_error(err);
let err = map_api_error_for_auth(err, client_setup.auth.as_ref());
inference_trace_attempt.record_failed(
&err,
response_debug_context.request_id.as_deref(),
@@ -1398,12 +1419,13 @@ impl ModelClientSession {
unauthorized_transport,
&mut auth_recovery,
session_telemetry,
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
)
.await?,
);
continue;
}
Err(err) => return Err(map_api_error(err)),
Err(err) => return Err(map_api_error_for_auth(err, client_setup.auth.as_ref())),
}
let mut ws_request = self.prepare_websocket_request(ws_payload, &request);
@@ -1429,7 +1451,7 @@ impl ModelClientSession {
.map_err(|err| {
let response_debug_context =
extract_response_debug_context_from_api_error(&err);
let err = map_api_error(err);
let err = map_api_error_for_auth(err, client_setup.auth.as_ref());
inference_trace_attempt.record_failed(
&err,
response_debug_context.request_id.as_deref(),
@@ -1954,6 +1976,7 @@ async fn handle_unauthorized(
transport: TransportError,
auth_recovery: &mut Option<UnauthorizedRecovery>,
session_telemetry: &SessionTelemetry,
auth_mode: Option<AuthMode>,
) -> Result<UnauthorizedRecoveryExecution> {
let debug = extract_response_debug_context(&transport);
if let Some(recovery) = auth_recovery
@@ -2063,7 +2086,10 @@ async fn handle_unauthorized(
debug.auth_error_code.as_deref(),
);
Err(map_api_error(ApiError::Transport(transport)))
Err(map_api_key_auth_failure(
map_api_error(ApiError::Transport(transport)),
auth_mode,
))
}
fn api_error_http_status(error: &ApiError) -> Option<u16> {

View File

@@ -7,6 +7,7 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER;
use super::X_CODEX_TURN_METADATA_HEADER;
use super::X_CODEX_WINDOW_ID_HEADER;
use super::X_OPENAI_SUBAGENT_HEADER;
use super::map_api_key_auth_failure;
use crate::AttestationContext;
use crate::AttestationProvider;
use crate::GenerateAttestationFuture;
@@ -23,6 +24,8 @@ use codex_model_provider_info::create_oss_provider_with_base_url;
use codex_otel::SessionTelemetry;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::error::UnexpectedResponseError;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
@@ -37,6 +40,7 @@ use codex_rollout_trace::RolloutTrace;
use codex_rollout_trace::TraceWriter;
use codex_rollout_trace::replay_bundle;
use futures::StreamExt;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
@@ -478,6 +482,46 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
assert_eq!(auth_context.recovery_phase, Some("refresh_token"));
}
#[test]
fn api_key_unauthorized_error_points_to_recredentialing() {
let err = CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::UNAUTHORIZED,
body: r#"{"error":{"message":"Incorrect API key provided"}}"#.to_string(),
url: Some("https://api.openai.com/v1/responses".to_string()),
cf_ray: None,
request_id: Some("req-123".to_string()),
identity_authorization_error: None,
identity_error_code: None,
});
let mapped = map_api_key_auth_failure(err, Some(AuthMode::ApiKey));
assert_eq!(
mapped.to_string(),
"The configured API key is invalid or unusable. Update your API key and try again."
);
}
#[test]
fn chatgpt_unauthorized_error_keeps_original_status_context() {
let err = CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::UNAUTHORIZED,
body: r#"{"error":{"message":"Workspace is not authorized"}}"#.to_string(),
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
cf_ray: None,
request_id: Some("req-456".to_string()),
identity_authorization_error: None,
identity_error_code: None,
});
let mapped = map_api_key_auth_failure(err, Some(AuthMode::Chatgpt));
assert_eq!(
mapped.to_string(),
"unexpected status 401 Unauthorized: Workspace is not authorized, url: https://chatgpt.com/backend-api/codex/responses, request id: req-456"
);
}
fn model_client_with_counting_attestation(
include_attestation: bool,
) -> (ModelClient, Arc<AtomicUsize>) {