fix: align OpenAI real-chain behavior

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-20 21:03:36 +08:00
co-authored by Codex
parent e06c90c38c
commit 0cf99a8b2e
10 changed files with 1307 additions and 21 deletions
+27 -15
View File
@@ -1737,6 +1737,7 @@ impl ChatDispatchService {
);
}
};
let response_id = body.get("id").and_then(Value::as_str);
self.cloudflare.reset_account_recovery(&account_id).await;
if let Some(usage) = response.usage {
self.account_pool
@@ -1754,6 +1755,7 @@ impl ChatDispatchService {
level: EventLevel::Info,
message: "v1 chat completions completed",
metadata: serde_json::json!({
"responseId": response_id,
"stream": false,
"transport": backend_transport_name(response.transport),
"usage": response.usage,
@@ -2605,6 +2607,7 @@ impl ResponseDispatchService {
match collected_response {
CollectedResponse::Completed(body) => {
let response_id = body.get("id").and_then(Value::as_str);
self.cloudflare.reset_account_recovery(&account.id).await;
self.account_pool
.sync_passive_rate_limit_headers(&account, &response.rate_limit_headers)
@@ -2633,6 +2636,7 @@ impl ResponseDispatchService {
level: EventLevel::Info,
message: "v1 responses completed",
metadata: serde_json::json!({
"responseId": response_id,
"stream": false,
"transport": backend_transport_name(response.transport),
"usage": response.usage,
@@ -5301,21 +5305,7 @@ fn insert_exhausted_dispatch_metadata(
}
fn status_code_for_response_dispatch_error(error: &ResponseDispatchError) -> i64 {
match error {
ResponseDispatchError::NoActiveAccount | ResponseDispatchError::AccountStore => 503,
ResponseDispatchError::QuotaExhausted { .. } => 402,
ResponseDispatchError::RateLimited { .. } => 429,
ResponseDispatchError::Expired { .. } | ResponseDispatchError::Disabled { .. } => 401,
ResponseDispatchError::Banned { status_code, .. } => i64::from(*status_code),
ResponseDispatchError::CloudflareChallenge { .. }
| ResponseDispatchError::CloudflarePathBlocked { .. }
| ResponseDispatchError::InvalidSse(_)
| ResponseDispatchError::MissingCompleted
| ResponseDispatchError::EmptyUpstreamResponse
| ResponseDispatchError::Failed(_) => 502,
ResponseDispatchError::ModelUnsupported { .. } => 400,
ResponseDispatchError::Upstream(error) => status_code_for_upstream_error(error),
}
i64::from(error.http_status_code())
}
fn status_code_for_chat_dispatch_error(error: &ChatDispatchError) -> i64 {
@@ -5612,6 +5602,28 @@ pub enum ResponseDispatchError {
Failed(ResponsesSseFailure),
}
impl ResponseDispatchError {
/// HTTP status code that best represents this dispatch failure to API clients.
#[must_use]
pub fn http_status_code(&self) -> u16 {
match self {
Self::NoActiveAccount | Self::AccountStore => 503,
Self::QuotaExhausted { .. } => 402,
Self::RateLimited { .. } => 429,
Self::Expired { .. } | Self::Disabled { .. } => 401,
Self::Banned { status_code, .. } => *status_code,
Self::CloudflareChallenge { .. }
| Self::CloudflarePathBlocked { .. }
| Self::InvalidSse(_)
| Self::MissingCompleted
| Self::EmptyUpstreamResponse
| Self::Failed(_) => 502,
Self::ModelUnsupported { .. } => 400,
Self::Upstream(error) => upstream_error_http_status(error),
}
}
}
/// Responses live SSE body stream error.
#[derive(Debug, Error)]
pub enum ResponseDispatchStreamError {
+1 -1
View File
@@ -318,7 +318,7 @@ fn fingerprint_diagnostics(fingerprint: &Fingerprint) -> FingerprintDiagnostics
}
}
fn is_local_debug_request(headers: &HeaderMap) -> bool {
pub(super) fn is_local_debug_request(headers: &HeaderMap) -> bool {
forwarded_header_is_local(headers, "x-forwarded-for")
&& forwarded_header_is_local(headers, "x-real-ip")
}
+6 -3
View File
@@ -11,7 +11,7 @@ use serde_json::{json, Value};
use codex_proxy_core::models::catalog::ModelCatalog;
use codex_proxy_runtime::state::AppState;
use super::auth::authorize_client_api_key;
use super::{auth::authorize_client_api_key, diagnostics::is_local_debug_request};
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
@@ -82,8 +82,11 @@ pub async fn model_info(
/// `GET /debug/models`
pub async fn debug_models(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response();
if !is_local_debug_request(&headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "debug endpoint is local-only" })),
);
}
let catalog = model_catalog_for_state(&state).await;
+3 -2
View File
@@ -448,8 +448,9 @@ fn response_dispatch_compact_error_response(error: ResponseDispatchError) -> Res
ResponseDispatchError::NoActiveAccount | ResponseDispatchError::AccountStore => {
responses_no_available_accounts_response().into_response()
}
ResponseDispatchError::Upstream(_) => responses_dispatch_error_response(
StatusCode::BAD_GATEWAY,
ResponseDispatchError::Upstream(error) => responses_dispatch_error_response(
StatusCode::from_u16(ResponseDispatchError::Upstream(error).http_status_code())
.unwrap_or(StatusCode::BAD_GATEWAY),
"Upstream Codex request failed",
)
.into_response(),
@@ -1237,6 +1237,7 @@ struct ResponseEventLog {
account_id: Option<String>,
route: Option<String>,
status_code: Option<i64>,
response_id: Option<String>,
metadata_json: String,
}
@@ -1267,6 +1268,7 @@ async fn latest_event_log(pool: &SqlitePool, kind: &str) -> ResponseEventLog {
account_id: event.account_id,
route: event.route,
status_code: event.status_code,
response_id: event.response_id,
metadata_json: event.metadata.to_string(),
}
}
@@ -83,8 +83,12 @@ async fn chat_completions_should_dispatch_to_codex_and_return_openai_response()
assert_eq!(event.account_id.as_deref(), Some("acct_chat"));
assert_eq!(event.route.as_deref(), Some("/v1/chat/completions"));
assert_eq!(event.status_code, Some(200));
let response_id = event.response_id.as_deref().unwrap_or_default();
assert!(!response_id.is_empty());
assert!(response_id.starts_with("chatcmpl-"));
assert_eq!(metadata["route"], "/v1/chat/completions");
assert_eq!(metadata["apiKind"], "chat");
assert_eq!(metadata["responseId"], response_id);
assert_eq!(metadata["stream"], false);
assert_eq!(metadata["transport"], "http_sse");
assert_eq!(metadata["usage"]["inputTokens"], 9);
@@ -295,6 +295,68 @@ async fn responses_compact_should_return_rate_limit_error_when_fallback_is_exhau
.is_some_and(|value| value.contains("compact quota reached")));
}
#[tokio::test]
async fn responses_compact_should_preserve_upstream_client_error_status() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses/compact"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {
"code": "invalid_encrypted_content",
"message": "The encrypted content could not be verified."
}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/compact")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.header("x-request-id", "req_compact_invalid_encrypted_content")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["type"], "error");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "codex_api_error");
let event = latest_response_event_log(&pool).await;
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert_eq!(
event.request_id.as_deref(),
Some("req_compact_invalid_encrypted_content")
);
assert_eq!(event.route.as_deref(), Some("/v1/responses/compact"));
assert_eq!(event.status_code, Some(400));
assert_eq!(event.level, "error");
assert_eq!(metadata["route"], "/v1/responses/compact");
assert_eq!(metadata["compact"], true);
assert_eq!(metadata["failed"], true);
assert_eq!(metadata["failureClass"], "upstream");
assert_eq!(metadata["upstreamStatus"], 400);
assert!(metadata["error"]
.as_str()
.is_some_and(|value| value.contains("invalid_encrypted_content")));
}
#[tokio::test]
async fn responses_compact_should_return_responses_error_when_no_accounts_are_available() {
let server = MockServer::start().await;
@@ -70,6 +70,8 @@ async fn responses_should_use_imported_account_record_usage_cookie_and_event_log
);
assert_eq!(event.account_id.as_deref(), Some("acct_chat"));
assert_eq!(event.status_code, Some(200));
assert_eq!(event.response_id.as_deref(), Some("resp_usage"));
assert_eq!(metadata["responseId"], "resp_usage");
assert_eq!(metadata["stream"], false);
assert_eq!(metadata["usage"]["inputTokens"], 7);
}
@@ -106,6 +106,34 @@ async fn debug_fingerprint_should_return_runtime_fingerprint_summary() {
assert_eq!(body["userAgent"], "Codex Desktop/27.100.200 (linux; x64)");
}
#[tokio::test]
async fn debug_models_should_return_catalog_debug_without_client_api_key() {
let (app, _dir) = test_app_with_account(
"https://chatgpt.test/backend-api".to_string(),
diagnostics_fingerprint(),
)
.await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/debug/models")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert!(body["models"].is_array());
assert!(!serde_json::to_string(&body)
.unwrap()
.contains("access-secret"));
}
#[tokio::test]
async fn debug_upstream_should_probe_codex_models_endpoint_without_returning_secrets() {
let server = MockServer::start().await;
@@ -190,6 +218,7 @@ async fn debug_endpoints_should_reject_forwarded_remote_requests_without_probe()
.await
.unwrap();
let upstream = app
.clone()
.oneshot(
Request::builder()
.method("GET")
@@ -200,10 +229,22 @@ async fn debug_endpoints_should_reject_forwarded_remote_requests_without_probe()
)
.await
.unwrap();
let models = app
.oneshot(
Request::builder()
.method("GET")
.uri("/debug/models")
.header("x-real-ip", "203.0.113.60")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(diagnostics.status(), StatusCode::FORBIDDEN);
assert_eq!(fingerprint.status(), StatusCode::FORBIDDEN);
assert_eq!(upstream.status(), StatusCode::FORBIDDEN);
assert_eq!(models.status(), StatusCode::FORBIDDEN);
}
async fn test_app_with_account(
File diff suppressed because it is too large Load Diff