Preserve Guardian request metadata with custom OpenAI base URLs (#57439)

## Why

Custom OpenAI base URLs caused Codex backend authentication to lose Responses headers and Guardian metadata when the URL did not match a recognized backend route.

## What changed

Apply model-scoped Responses headers, Guardian reviewer and classifier markers, and eligible ChatGPT parent credit requests based on the OpenAI provider and authentication configuration, regardless of the base URL. Keep API-key requests and other providers excluded from this behavior.

## Testing

Extend coverage for custom `/v1` URLs and direct `/api/codex` endpoints with ChatGPT, header-based, and API-key authentication, including HTTP fallback and WebSocket requests.

GitOrigin-RevId: cc4bb5b4a1a6596904ecf8965237c7a4f09e112b
This commit is contained in:
jif
2026-10-27 12:17:14 +00:00
committed by copyberry
parent 5557e08853
commit e1dd7ed1ba
7 changed files with 44 additions and 36 deletions
+1 -6
View File
@@ -1337,11 +1337,7 @@ impl ModelClient {
fn responses_headers(&self, auth: Option<&CodexAuth>, model: &str) -> ApiHeaderMap {
self.codex_responses_headers
.as_ref()
.filter(|config| {
config.model == model
&& self.uses_codex_backend(auth)
&& self.state.provider.info().supports_codex_backend_routes()
})
.filter(|config| config.model == model && self.uses_codex_backend(auth))
.map(|config| config.headers.clone())
.unwrap_or_default()
}
@@ -1382,7 +1378,6 @@ impl ModelClient {
Some(CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_))
)
&& self.uses_codex_backend(auth)
&& self.state.provider.info().supports_codex_backend_routes()
{
metadata
.get_or_insert_with(HashMap::new)
+2 -2
View File
@@ -2097,7 +2097,7 @@ fn thread_responses_headers_are_scoped_to_model_and_backend_auth() {
let chatgpt_auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let api_key_auth = CodexAuth::from_api_key("test-api-key");
for (auth, model, expected) in [
(Some(&chatgpt_auth), "selected-model", headers),
(Some(&chatgpt_auth), "selected-model", headers.clone()),
(Some(&chatgpt_auth), "other-model", http::HeaderMap::new()),
(
Some(&api_key_auth),
@@ -2117,7 +2117,7 @@ fn thread_responses_headers_are_scoped_to_model_and_backend_auth() {
);
assert_eq!(
model_client.responses_headers(Some(&chatgpt_auth), "selected-model"),
http::HeaderMap::new(),
headers,
);
}
+4 -3
View File
@@ -5,9 +5,10 @@ use http::HeaderMap;
/// Seed in `StartThreadOptions::thread_extension_init` to supply Responses headers.
///
/// Applied only to the selected model with Codex backend auth and routing. The headers
/// are sent on HTTP requests and WebSocket handshakes; changes require a new socket.
/// Other provider endpoints and unrelated threads do not inherit these headers.
/// Applied only to the selected model on the OpenAI provider with Codex backend
/// auth, regardless of the base URL. The headers are sent on HTTP requests and
/// WebSocket handshakes; changes require a new socket. Other providers and
/// unrelated threads do not inherit these headers.
#[derive(Clone)]
pub struct CodexResponsesHeaders {
pub model: String,
+19 -22
View File
@@ -24,6 +24,7 @@ use codex_extension_api::ToolStartInput;
use codex_features::CurrentTimeSource;
use codex_features::Feature;
use codex_history::RolloutItem;
use codex_login::AuthHeaders;
use codex_login::CodexAuth;
use codex_models_manager::manager::RefreshStrategy;
use codex_otel::MetricsClient;
@@ -144,8 +145,10 @@ impl TimeProvider for RecordingTimeProvider {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(CodexAuth::from_api_key("test-api-key"), "OpenAI", "/v1", true, "/v1/responses", true; "api_key_uses_responses")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/backend-api/codex", false, "/backend-api/codex/responses", true; "chatgpt_marks_guardian_by_default")]
#[test_case(CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new())), "OpenAI", "/api/codex", false, "/api/codex/responses", true; "header_auth_marks_guardian_on_direct_backend")]
#[test_case(CodexAuth::from_api_key("test-api-key"), "OpenAI", "/api/codex", true, "/api/codex/responses", true; "api_key_does_not_mark_guardian_on_direct_backend")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/backend-api/codex", true, "/backend-api/codex/responses", true; "legacy_opt_in_still_accepted")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/v1", true, "/v1/responses", true; "custom_openai_url_uses_responses")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/v1", true, "/v1/responses", true; "custom_openai_url_marks_guardian")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "Custom", "/backend-api/codex", true, "/backend-api/codex/responses", true; "custom_provider_uses_responses")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/backend-api/codex", true, "/backend-api/codex/responses", false; "retry_without_response_id_keeps_last_parent")]
async fn guardian_session_inherits_parent_http_fallback(
@@ -156,8 +159,8 @@ async fn guardian_session_inherits_parent_http_fallback(
expected_guardian_path: &str,
response_id_present: bool,
) -> Result<()> {
let credits_enabled =
auth.uses_codex_backend() && provider_name == "OpenAI" && base_path == "/backend-api/codex";
let credits_enabled = auth.uses_codex_backend() && provider_name == "OpenAI";
let requests_parent_credits = credits_enabled && auth.is_chatgpt_auth();
skip_if_no_network!(Ok(()));
let configured_policy = "Use the task-configured Guardian policy.";
@@ -299,7 +302,7 @@ async fn guardian_session_inherits_parent_http_fallback(
.cloned(),
body["client_metadata"].get("parent_response_id"),
),
(credits_enabled.then(|| json!("true")), None)
(requests_parent_credits.then(|| json!("true")), None)
);
}
}
@@ -878,18 +881,21 @@ async fn guardian_requests_record_only_their_own_tool_calls() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(CodexAuth::from_api_key("test-api-key"), true, "gpt-5.6-luna"; "api_key_uses_luna_with_responses_lite")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), true, "codex-auto-review"; "chatgpt_uses_codex_auto_review")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), false, "codex-auto-review"; "chatgpt_without_free_guardian")]
#[test_case(CodexAuth::from_api_key("test-api-key"), "/v1", true, "gpt-5.6-luna"; "api_key_uses_luna_with_responses_lite")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "/backend-api/codex", true, "codex-auto-review"; "chatgpt_uses_codex_auto_review")]
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "/backend-api/codex", false, "codex-auto-review"; "chatgpt_without_free_guardian")]
#[test_case(CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new())), "/api/codex", false, "codex-auto-review"; "header_auth_uses_codex_auto_review_on_direct_backend")]
#[test_case(CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new())), "/v1", false, "codex-auto-review"; "header_auth_marks_guardian_on_custom_url")]
async fn guardian_session_prewarms_and_is_reused_for_first_review(
auth: CodexAuth,
base_path: &str,
free_guardian: bool,
expected_model: &str,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let uses_codex_backend = auth.uses_codex_backend();
let credits_enabled = uses_codex_backend;
let credits_enabled = auth.uses_codex_backend();
let requests_parent_credits = credits_enabled && auth.is_chatgpt_auth();
let bundled_models = codex_models_manager::bundled_models_response()?.models;
let catalog_auto_review = bundled_models
.iter()
@@ -950,7 +956,7 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review(
let time_provider = Arc::new(RecordingTimeProvider {
thread_ids: Mutex::new(Vec::new()),
});
let backend_base_url = format!("{}/backend-api/codex", server.uri());
let backend_base_url = format!("{}{base_path}", server.uri());
let mut builder = test_codex()
.with_auth(auth)
.with_pre_build_hook(move |home| {
@@ -961,9 +967,7 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review(
.expect("Guardian endpoint configuration should be written");
})
.with_config(move |config| {
if uses_codex_backend {
config.model_provider.base_url = Some(backend_base_url);
}
config.model_provider.base_url = Some(backend_base_url);
let rules_dir = config.codex_home.join("rules");
fs::create_dir_all(&rules_dir).expect("create execution policy directory");
let policy_justification = format!(
@@ -1074,7 +1078,7 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review(
(
credits_enabled.then(|| json!(parent_response_id)),
None,
credits_enabled.then(|| json!("true")),
requests_parent_credits.then(|| json!("true")),
None,
)
);
@@ -1207,14 +1211,7 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review(
for handshake in server.handshakes() {
let is_guardian = handshake.header("x-openai-subagent").as_deref() == Some("guardian");
let is_guardian_request = credits_enabled && is_guardian;
assert_eq!(
handshake.uri(),
if uses_codex_backend {
"/backend-api/codex/responses"
} else {
"/v1/responses"
}
);
assert_eq!(handshake.uri(), format!("{base_path}/responses"));
assert_eq!(
handshake.header("x-codex-guardian").as_deref(),
is_guardian_request.then_some("reviewer")
@@ -439,7 +439,7 @@ impl ConnectionPool {
.await
.as_ref()
.is_some_and(CodexAuth::uses_codex_backend)
&& provider.supports_codex_backend_routes()
&& provider.is_openai()
&& provider.requires_openai_auth
&& provider.env_key.is_none()
&& provider.experimental_bearer_token.is_none()
@@ -8,6 +8,7 @@ use codex_guardian_context::PreviousReviews;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::AgentIdentityAuthPolicy;
use codex_login::AuthHeaders;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
@@ -541,11 +542,23 @@ async fn classifier_sends_guardian_header_only_with_codex_backend_auth() -> Resu
None,
),
(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
"/v1",
CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new())),
"/api/codex",
Some("classifier"),
None,
),
(
CodexAuth::from_api_key("test-api-key"),
"/api/codex",
None,
Some("priority"),
),
(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
"/v1",
Some("classifier"),
None,
),
(
CodexAuth::from_api_key("test-api-key"),
"/v1",
@@ -245,6 +245,8 @@ fn codex_backend_routes_require_codex_base_url() {
(None, true),
(Some(CHATGPT_CODEX_BASE_URL), true),
(Some("https://chatgpt-staging.com/backend-api/codex/"), true),
(Some("https://backend.example.com/api/codex"), false),
(Some("https://backend.example.com/api/codex/"), false),
(Some("https://proxy.example.com/v1"), false),
] {
let provider = ModelProviderInfo::create_openai_provider(base_url.map(str::to_owned));