diff --git a/docs/official-codex-request-chain.md b/docs/official-codex-request-chain.md index cdaf794ac..79c16c47f 100644 --- a/docs/official-codex-request-chain.md +++ b/docs/official-codex-request-chain.md @@ -192,6 +192,7 @@ - 首个文本帧是 `response.create`,payload 由 `CodexResponsesRequest` 序列化而来。 - WebSocket opening 会移除 `content-type` 和 `accept`,因为这两个是 HTTP/SSE 请求头,不属于 WS opening 业务头。 - 连接池 key 为 `base_url + account_id + conversation_id`,conversation id 优先取 `prompt_cache_key`,其次 `client_conversation_id`,再其次 `previous_response_id`。 +- 官方二进制有 60 分钟连接限制文案;当前项目连接池默认 `max_age_ms = 3_300_000`(55 分钟),低于官方限制。连接在 acquire、put 和后台 maintenance sweep 时都会按 `max_age` 回收,避免复用接近官方 60 分钟上限的 socket。 当前 WebSocket fallback: @@ -322,8 +323,8 @@ WebSocket 事件: - 403 body 命中 `cf-mitigated`、`cf-chl-bypass`、`_cf_chl`、`cf_chl`、`attention required`、`just a moment` 时进入 Cloudflare challenge cooldown。 - 404 空 body 作为 path-block,立即删除账号 cookie,多次后禁用账号。 - 调度层会在 Cloudflare challenge/path-block 后换号或返回明确错误。 -- 官方二进制出现 `This request has been flagged for possible cybersecurity risk.`。当前项目没有单独识别这个文案,若真实链路遇到,应作为独立风控/安全策略错误,而不是归入 quota 或 Cloudflare。 -- 官方错误枚举还包含 `usage_limit_exceeded`、`cyber_policy`、`response_stream_connection_failed`、`response_stream_disconnected` 等 CodexErrorInfo 类别;当前项目主要按 HTTP status、SSE failure code/message 和 WS wrapped error 分类。 +- 官方二进制出现 `This request has been flagged for possible cybersecurity risk.`。当前项目把 `cyber_policy` 归为请求内容/安全策略类 400,不作为 quota、Cloudflare 或账号封禁信号。 +- 官方错误枚举还包含 `context_window_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`cyber_policy`、`response_stream_connection_failed`、`response_stream_disconnected` 等 CodexErrorInfo 类别。当前项目按 HTTP status、SSE failure code/message 和 WS wrapped error 分类;其中 WS `cyber_policy`/`invalid_prompt`/`context_length_exceeded`/`invalid_request` 会映射 400,SSE `cyber_policy`/`invalid_prompt`/`context_window*`/`bad_request` 也会映射 400。 ### 额度和限流 @@ -402,8 +403,8 @@ Usage 请求路径按 base URL 选择: - 原生二进制不能在当前 Linux 环境执行,无法做动态断点或真实 app-server 调用链追踪。 - 官方包里没有 source map,JS bundle 为压缩产物;函数名和局部变量名不能视为稳定 API。 - WebSocket 降级策略目前按现有项目实现理解为连接/transport 错误可降级,显式上游状态错误不降级;官方二进制字符串能证明有降级路径,但不能单独证明完整条件矩阵。 -- 官方有 WebSocket 60 分钟连接限制文案;当前项目 WebSocket pool 只按本地 pool TTL 控制,未显式实现官方 60 分钟上限。 -- 官方有 `cyber_policy` 风控语义;当前项目还没有把对应文案/错误码拆成独立错误类型。 +- 官方有 WebSocket 60 分钟连接限制文案;当前项目用 55 分钟默认 pool TTL 主动规避该上限,但没有实现从上游错误中动态调整 TTL。 +- 官方有 `cyber_policy` 风控语义;当前项目已将 WS/SSE 中的 `cyber_policy` 归入 400 类请求失败,但没有创建单独的账号状态或轮换策略。 ## 实现建议 diff --git a/src/proxy/dispatch/responses.rs b/src/proxy/dispatch/responses.rs index 7b2b16e82..8cfda9ed7 100644 --- a/src/proxy/dispatch/responses.rs +++ b/src/proxy/dispatch/responses.rs @@ -2740,6 +2740,13 @@ fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 { if code.contains("invalid_request") || code.contains("not_found") { return 400; } + if code.contains("context_window") + || code.contains("invalid_prompt") + || code.contains("cyber_policy") + || code.contains("bad_request") + { + return 400; + } if code.contains("rate_limit") || code.contains("usage_limit") { return 429; } @@ -2757,6 +2764,9 @@ fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 { if code.contains("payment") || code.contains("quota") { return 402; } + if code.contains("server_overloaded") { + return 503; + } 502 } @@ -2927,8 +2937,8 @@ impl ResponseDispatchError { | Self::CloudflarePathBlocked { .. } | Self::InvalidSse(_) | Self::MissingCompleted - | Self::EmptyUpstreamResponse - | Self::Failed(_) => 502, + | Self::EmptyUpstreamResponse => 502, + Self::Failed(failure) => stream_failure_http_status(failure), Self::ModelUnsupported { .. } => 400, Self::Upstream(error) => upstream_error_http_status(error), } diff --git a/src/proxy/openai/errors.rs b/src/proxy/openai/errors.rs index a83ec3396..4ffebd76d 100644 --- a/src/proxy/openai/errors.rs +++ b/src/proxy/openai/errors.rs @@ -311,6 +311,9 @@ fn response_dispatch_error_type_and_code( | ResponseDispatchError::EmptyUpstreamResponse => { ("server_error", "invalid_upstream_response") } + ResponseDispatchError::Failed(_) => responses_error_type_and_code_for_status( + status_from_u16(error.http_status_code(), StatusCode::BAD_GATEWAY), + ), _ => ("server_error", "upstream_error"), } } diff --git a/tests/proxy/dispatch/chat_upstream/responses_recovery.rs b/tests/proxy/dispatch/chat_upstream/responses_recovery.rs index 2cf713a56..77d2f89dc 100644 --- a/tests/proxy/dispatch/chat_upstream/responses_recovery.rs +++ b/tests/proxy/dispatch/chat_upstream/responses_recovery.rs @@ -46,6 +46,51 @@ async fn responses_should_dispatch_to_codex_and_return_completed_response() { assert_eq!(body["usage"]["output_tokens"], 2); } +#[tokio::test] +async fn responses_should_classify_sse_cyber_policy_failure_as_bad_request() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(concat!( + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_cyber_policy\",\"object\":\"response\",\"status\":\"failed\",\"error\":{\"code\":\"cyber_policy\",\"message\":\"This request has been flagged for possible cybersecurity risk.\"}}}\n\n" + )), + ) + .mount(&server) + .await; + + let (app, api_key, _dir) = test_app_with_account(server.uri()).await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Security assessment"}], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["type"], "invalid_request_error"); + assert_eq!(body["error"]["code"], "codex_api_error"); +} + #[tokio::test] async fn responses_should_return_no_available_accounts_error_when_no_accounts_are_available() { let server = MockServer::start().await;