mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: align OpenAI real-chain behavior
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user