Compare commits

..
25 Commits
Author SHA1 Message Date
ZyyandCodex 65fbbcb3ff chore: bump version to 3.3.22
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 16:52:27 +08:00
ZyyandCodex bd086a7e3c fix(observability): show reasoning token usage
Expose aggregate reasoning tokens on account responses and use them in the token structure panel while retaining cache-write fields for API compatibility.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 16:51:18 +08:00
ZyyandCodex 3554ba13db fix(openai): preserve websocket turn state on failure
Bridge both metadata event names, project sticky state per response.create, and return captured state on pre-terminal failures without replaying ambiguous requests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 16:51:01 +08:00
ZyyandCodex bcccebc9fa chore: bump version to 3.3.21
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:52:25 +08:00
ZyyandCodex d67b46a9e2 fix(frontend): default Codex websocket config off
Keep inline switch text legible in the off state and omit WebSocket settings from generated Codex and CCSwitch configuration unless explicitly enabled.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:50:47 +08:00
ZyyandCodex 20c6d33cf4 fix(observability): retain recovered transport failures
Link successful same-session HTTP fallbacks to pending WebSocket failures, keep recovered records visible in Ops diagnostics, and label their recovery state.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:50:41 +08:00
ZyyandCodex cf0e84c548 fix(openai): isolate websocket continuation lanes
Include the downstream connection identity in pooled upstream WebSocket keys so concurrent clients cannot contend for connection-local response state.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:50:34 +08:00
ZyyandCodex 2332af9a08 chore: bump version to 3.3.20
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 00:47:35 +08:00
ZyyandCodex 024fc9fcf0 fix(openai): fall back after websocket fast-path misses
Treat the 800 ms fast-path budget as a routing decision instead of a provider error, while preserving background preconnect for the next request.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 00:36:50 +08:00
ZyyandCodex 4220f4e095 fix(openai): recover websocket sessions after ambiguous failures
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 23:33:26 +08:00
ZyyandCodex a709acb149 chore: bump version to 3.3.19
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:56:10 +08:00
ZyyandCodex 466842acd8 fix(observability): search usage by API key
Resolve sk_ searches through client key references while only sending the visible key prefix from the frontend, and clarify the request detail search hint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:38:29 +08:00
ZyyandCodex b91f28f95b fix(frontend): align configuration and usage copy
Generate the displayed Codex files and CCSwitch import payload from one source, carry the full settings through deep links, and clarify the quota percentage label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 16:37:53 +08:00
ZyyandCodex 87164db3da chore: bump version to 3.3.18
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 15:41:50 +08:00
ZyyandCodex ad16dec3db fix(openai): align official client routes
Remove unsupported review and model extension routes, preserve review semantics on standard Responses requests, and refine usage observability for review and empty performance data.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 15:37:14 +08:00
ZyyandCodex a0da0f4713 feat(openai): proxy standalone search
Add /v1/alpha/search as a transparent raw JSON endpoint, route requests through the OpenAI credential pipeline, and preserve account-scoped turn metadata and upstream response headers.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 14:27:15 +08:00
ZyyandCodex 65e5cde60d chore: bump version to 3.3.17
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 12:44:42 +08:00
ZyyandCodex 0f0b464f4e fix(frontend): refine account usage presentation
Select quota summaries by the most recently used model, streamline dashboard account identities, and align diagnostic client metadata with successful records.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 12:21:15 +08:00
ZyyandCodex bd223f6540 chore: bump version to 3.3.16
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 22:45:09 +08:00
ZyyandCodex 168be0ca68 fix(openai): add continuation recovery observability
Persist stable continuation and WebSocket lifecycle facts, correlate successful client replays, and hide recovered failures from default business metrics.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 21:38:48 +08:00
ZyyandCodex f2e91bbdaa fix(openai): preserve websocket continuation routing
Limit session-sticky HTTP fallback to new chains, keep continuation requests on their safe transport, and classify rebuild-required failures separately from account health.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 18:21:30 +08:00
ZyyandCodex d0c9248478 fix(openai): refine smart scheduler failure scoring
Limit account feedback to an explicit upstream reason allowlist, aggregate same-account retries, and decay failure rates over time.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 15:08:31 +08:00
ZyyandCodex 22c697bbff fix(usage): refine record details and error filters
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 14:43:44 +08:00
ZyyandCodex 08d518324a fix(frontend): refine account quota layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 12:14:03 +08:00
ZyyandCodex dfacb20e43 fix(frontend): tighten usage attempt table layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-01 19:46:49 +08:00
138 changed files with 6855 additions and 1495 deletions
+1 -4
View File
@@ -23,7 +23,7 @@
| 领域 | 当前能力 |
| --- | --- |
| 数据面 | Responses JSON、SSE、WebSocket、review 子代理、Images 生成/编辑与模型目录 |
| 数据面 | Responses JSON、SSE、WebSocket(含 Codex 子代理语义)、Images 生成/编辑与模型目录 |
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
@@ -136,12 +136,9 @@ curl http://127.0.0.1:8080/v1/responses \
| --- | --- |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
@@ -135,13 +135,11 @@ pub struct OpsErrorFilter {
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
@@ -793,6 +791,18 @@ pub struct OpsError {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
@@ -230,6 +230,8 @@ pub(super) fn account_usage_view(
output_tokens_display: display_optional_tokens(usage.output_tokens),
cached_tokens: usage.cached_tokens,
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
reasoning_tokens: usage.reasoning_tokens,
reasoning_tokens_display: display_optional_tokens(usage.reasoning_tokens),
image_input_tokens: usage.image_input_tokens,
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
image_output_tokens: usage.image_output_tokens,
@@ -347,6 +349,8 @@ pub(super) fn empty_account_usage() -> AccountUsageView {
output_tokens_display: "—".to_owned(),
cached_tokens: None,
cached_tokens_display: "—".to_owned(),
reasoning_tokens: None,
reasoning_tokens_display: "—".to_owned(),
image_input_tokens: None,
image_input_tokens_display: "—".to_owned(),
image_output_tokens: None,
@@ -276,6 +276,8 @@ pub struct AccountUsageView {
pub output_tokens_display: String,
pub cached_tokens: Option<u64>,
pub cached_tokens_display: String,
pub reasoning_tokens: Option<u64>,
pub reasoning_tokens_display: String,
pub image_input_tokens: Option<u64>,
pub image_input_tokens_display: String,
pub image_output_tokens: Option<u64>,
@@ -578,6 +578,18 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
component: error.component,
attempt_id: None,
account_label,
continuation_affinity_hash: error.continuation_affinity_hash,
continuation_previous_response_id_hash: error.continuation_previous_response_id_hash,
continuation_unavailable_reason: error.continuation_unavailable_reason,
upstream_connection_id: error.upstream_connection_id,
upstream_connection_exit_reason: error.upstream_connection_exit_reason,
upstream_connection_age_ms: error.upstream_connection_age_ms,
upstream_connection_idle_ms: error.upstream_connection_idle_ms,
recovery_request_id: error.recovery_request_id,
recovered_at: error.recovered_at,
recovery_attempt_count: error.recovery_attempt_count,
recovery_retry_delay_ms: error.recovery_retry_delay_ms,
recovery_total_latency_ms: error.recovery_total_latency_ms,
},
created_at: error.occurred_at,
created_at_display: china_datetime(&error.occurred_at),
@@ -107,14 +107,12 @@ pub struct OpsQuery {
pub provider: Option<String>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub failure_class: Option<String>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
@@ -352,13 +350,11 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.kind.clone()),
endpoint: non_empty(query.route.clone()),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
failure_kind: non_empty(query.failure_class.clone()),
status_code,
search: non_empty(query.search.clone()),
},
@@ -805,4 +805,16 @@ pub struct OpsErrorMetadataView {
pub component: String,
pub attempt_id: Option<String>,
pub account_label: Option<String>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
}
@@ -3,22 +3,21 @@
use std::net::SocketAddr;
use axum::{
body::{Body, Bytes},
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
http::HeaderMap,
response::Response,
};
use gateway_core::engine::execution::StartedExecution;
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
use serde_json::{Map, Value};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
error::{engine_error_response, gateway_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::request_client_context,
};
const OPENAI_PROTOCOL: &str = "openai";
@@ -82,13 +81,13 @@ async fn handle_image_request(
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_image(client, operation, client_ip, user_agent, endpoint)
.start_provider_endpoint(client, operation, client_ip, user_agent, endpoint)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_image_response(started).await
collect_raw_json_response(started).await
}
fn image_operation(
@@ -118,116 +117,3 @@ fn image_operation(
kind, payload,
)))
}
async fn collect_image_response(started: StartedExecution) -> Response {
let mut execution = PendingExecution::new(started.session);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let body = match raw_json_body(events) {
Some(body) => body,
None => {
let response = invalid_upstream_response();
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let status = session
.response_status_code()
.and_then(|status| StatusCode::from_u16(status).ok())
.filter(StatusCode::is_success)
.unwrap_or(StatusCode::OK);
let response_headers = session.response_headers().to_vec();
let response = json_body_response(body, status, &response_headers);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
execution.cancel_and_finalize().await;
return invalid_upstream_response();
}
execution.disarm();
response
}
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut body = None;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
continue;
};
if body.replace(raw).is_some() {
return None;
}
}
body
}
fn json_body_response(
body: Bytes,
status: StatusCode,
response_headers: &[ProviderResponseHeader],
) -> Response {
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
apply_response_headers(response, response_headers)
}
fn apply_response_headers(
mut response: Response,
response_headers: &[ProviderResponseHeader],
) -> Response {
let connection_options =
crate::openai::responses::response_connection_options(response_headers);
for header in response_headers {
if !crate::openai::responses::response_header_is_forwardable(
header.name(),
&connection_options,
) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
response
}
fn invalid_upstream_response() -> Response {
protocol_error_response(
StatusCode::BAD_GATEWAY,
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream image response.".to_owned(),
param: None,
},
},
)
.into_response()
}
@@ -4,6 +4,8 @@ pub mod auth;
pub mod error;
pub mod images;
pub mod models;
mod provider_endpoint;
pub mod responses;
pub mod router;
pub mod search;
pub(crate) mod service;
@@ -97,50 +97,6 @@ pub(crate) async fn model_detail(
.into_response()
}
/// `GET /v1/models/catalog`。
///
/// 新架构的目录由 core 按客户端 Provider 范围裁剪;这里不读取或重建任一
/// Provider 的 wire catalog,只恢复 Codex 客户端依赖的稳定展示合同。
pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: HeaderMap) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let models = service
.public_models(&client)
.into_iter()
.map(|model| catalog_model_json(model.as_str()))
.collect::<Vec<_>>();
(StatusCode::OK, Json(models)).into_response()
}
/// `GET /v1/models/{model_id}/info`。
pub(crate) async fn model_info(
State(state): State<ApiState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
};
if !service.contains_public_model(&client, &public_model) {
return model_not_found_response().into_response();
}
(
StatusCode::OK,
Json(catalog_model_json(public_model.as_str())),
)
.into_response()
}
fn openai_model_json(id: &str) -> Value {
json!({
"id": id,
@@ -150,22 +106,6 @@ fn openai_model_json(id: &str) -> Value {
})
}
fn catalog_model_json(id: &str) -> Value {
json!({
"id": id,
"displayName": id,
"description": "",
"isDefault": false,
"supportedReasoningEfforts": [],
"defaultReasoningEffort": "",
"inputModalities": ["text"],
"outputModalities": ["text"],
"supportsPersonality": false,
"upgrade": Value::Null,
"source": "gateway",
})
}
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
let id = profile.model().as_str();
let presentation = profile.presentation();
@@ -0,0 +1,124 @@
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界。
use axum::{
body::{Body, Bytes},
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::StartedExecution;
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use super::{
error::{engine_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody},
};
/// 收集一个 Provider 原生 JSON 响应,并在 Core commit 后按原始 bytes 交付。
pub(super) async fn collect_raw_json_response(started: StartedExecution) -> Response {
let mut execution = PendingExecution::new(started.session);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let body = match raw_json_body(events) {
Some(body) => body,
None => {
let response = invalid_upstream_response();
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let status = session
.response_status_code()
.and_then(|status| StatusCode::from_u16(status).ok())
.filter(StatusCode::is_success)
.unwrap_or(StatusCode::OK);
let response_headers = session.response_headers().to_vec();
let response = json_body_response(body, status, &response_headers);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
execution.cancel_and_finalize().await;
return invalid_upstream_response();
}
execution.disarm();
response
}
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut body = None;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
continue;
};
if body.replace(raw).is_some() {
return None;
}
}
body
}
fn json_body_response(
body: Bytes,
status: StatusCode,
response_headers: &[ProviderResponseHeader],
) -> Response {
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
apply_response_headers(response, response_headers)
}
fn apply_response_headers(
mut response: Response,
response_headers: &[ProviderResponseHeader],
) -> Response {
let connection_options = super::responses::response_connection_options(response_headers);
for header in response_headers {
if !super::responses::response_header_is_forwardable(header.name(), &connection_options) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
response
}
fn invalid_upstream_response() -> Response {
protocol_error_response(
StatusCode::BAD_GATEWAY,
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream JSON response.".to_owned(),
param: None,
},
},
)
.into_response()
}
@@ -14,8 +14,8 @@ use axum::{
response::{IntoResponse, Response},
};
use futures::stream;
use gateway_core::engine::CommitRequirement;
use gateway_core::engine::execution::{ClientTransport, ExecutionSession, StartedExecution};
use gateway_core::engine::{CommitRequirement, EngineError};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use gateway_core::lifecycle::ConnectionGuard;
@@ -32,7 +32,7 @@ use crate::openai::{
use super::{
OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError,
request::{decode_request_with_headers, decode_review_request_with_headers},
request::decode_request_with_headers,
};
/// `POST /v1/responses`。
@@ -41,37 +41,13 @@ pub(crate) async fn responses(
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, connect_info, headers, body, false).await
}
/// `POST /v1/responses/review`。
pub(crate) async fn review_responses(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, connect_info, headers, body, true).await
}
async fn handle_responses(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
review: bool,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let decoded = match if review {
decode_review_request_with_headers(&body, &headers)
} else {
decode_request_with_headers(&body, &headers)
} {
let decoded = match decode_request_with_headers(&body, &headers) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -100,11 +76,7 @@ async fn handle_responses(
} else {
ClientTransport::HttpJson
},
if review {
"/v1/responses/review"
} else {
"/v1/responses"
},
"/v1/responses",
)
.await
{
@@ -180,7 +152,8 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
let response_headers = session.response_headers().to_vec();
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
};
@@ -206,7 +179,7 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
.await
{
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
@@ -274,6 +247,13 @@ fn apply_response_headers(
response
}
fn engine_error_response_with_headers(
error: &EngineError,
response_headers: &[ProviderResponseHeader],
) -> Response {
apply_response_headers(engine_error_response(error), response_headers)
}
/// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。
pub async fn stream_execution_response(
session: Box<dyn ExecutionSession>,
@@ -295,7 +275,8 @@ pub async fn stream_execution_response(
return response;
}
Err(error) => {
let response = engine_error_response(&error);
let response_headers = session.response_headers().to_vec();
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
};
@@ -327,7 +308,7 @@ pub async fn stream_execution_response(
.await
{
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
let Some(session) = execution.into_session() else {
@@ -7,9 +7,9 @@ mod response;
pub mod websocket;
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(crate) use http::responses;
pub(super) use http::{PendingExecution, request_client_context};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use http::{responses, review_responses};
pub use request::{
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
decode_request_with_headers,
@@ -13,10 +13,10 @@ use serde_json::{Map, Value};
use super::error::RequestDecodeError;
const OPENAI_PROTOCOL: &str = "openai";
const REVIEW_SUBAGENT: &str = "review";
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
const CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata";
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const DOWNSTREAM_WEBSOCKET_CONNECTION_ID_CONTEXT_KEY: &str = "downstream_websocket_connection_id";
/// Responses 请求进入共享解码内核时的下游传输来源。
#[derive(Clone, Copy)]
@@ -36,6 +36,7 @@ pub struct OpenAiRequestHeaders {
include_timing_metrics: Option<String>,
codex_window_id: Option<String>,
parent_thread_id: Option<String>,
downstream_websocket_connection_id: Option<String>,
conversation_id: Option<String>,
session_id: Option<String>,
@@ -61,6 +62,7 @@ impl OpenAiRequestHeaders {
include_timing_metrics: header_string(headers, "x-responsesapi-include-timing-metrics"),
codex_window_id: header_string(headers, "x-codex-window-id"),
parent_thread_id: header_string(headers, "x-codex-parent-thread-id"),
downstream_websocket_connection_id: None,
conversation_id: header_string(headers, "conversation-id")
.or_else(|| header_string(headers, "conversation_id")),
session_id: header_string(headers, "session-id")
@@ -75,8 +77,18 @@ impl OpenAiRequestHeaders {
}
}
fn apply_subagent(&self, body: &mut Map<String, Value>, forced_subagent: Option<&str>) {
if let Some(subagent) = forced_subagent.or(self.subagent.as_deref()) {
/// 绑定代理为当前下游 WebSocket 分配的连接身份。
#[must_use]
pub(super) fn with_downstream_websocket_connection_id(
mut self,
connection_id: impl Into<String>,
) -> Self {
self.downstream_websocket_connection_id = Some(connection_id.into());
self
}
fn apply_subagent(&self, body: &mut Map<String, Value>) {
if let Some(subagent) = self.subagent.as_deref() {
inject_subagent_metadata(body, subagent);
}
}
@@ -110,6 +122,11 @@ impl OpenAiRequestHeaders {
"parent_thread_id",
self.parent_thread_id.as_ref(),
);
insert_protocol_context(
&mut context,
DOWNSTREAM_WEBSOCKET_CONNECTION_ID_CONTEXT_KEY,
self.downstream_websocket_connection_id.as_ref(),
);
insert_protocol_context(
&mut context,
"conversation_id",
@@ -301,20 +318,11 @@ pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, false, &OpenAiRequestHeaders::from_headers(headers))
}
/// 解码 `POST /v1/responses/review` 请求并冻结 review subagent 语义。
pub(super) fn decode_review_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, true, &OpenAiRequestHeaders::from_headers(headers))
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
}
pub(super) fn decode_request_inner(
body: &[u8],
review: bool,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let value =
@@ -322,13 +330,12 @@ pub(super) fn decode_request_inner(
let Value::Object(object) = value else {
return Err(RequestDecodeError::ExpectedObject);
};
decode_request_object(object, review, request_headers, RequestDecodeSource::Http)
decode_request_object(object, request_headers, RequestDecodeSource::Http)
}
/// 解码已解析的顶层 object;按下游传输来源恢复连接级协议上下文。
pub(super) fn decode_request_object(
mut object: Map<String, Value>,
review: bool,
request_headers: &OpenAiRequestHeaders,
source: RequestDecodeSource,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
@@ -359,7 +366,7 @@ pub(super) fn decode_request_object(
.map(|response_id| ContinuationIntent::PreviousResponseId(response_id.to_owned()))
.unwrap_or(ContinuationIntent::None);
request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT));
request_headers.apply_subagent(&mut object);
let frame_turn_metadata = match source {
RequestDecodeSource::Http => None,
RequestDecodeSource::WebSocketFrame => frame_turn_metadata(&object),
@@ -77,8 +77,8 @@ pub(super) async fn forward_execution(
return send_gateway_error(connection, &error, &request_id).await;
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return send_gateway_error(connection, &error, &request_id).await;
return send_initial_engine_error(connection, &mut execution, &error, &request_id)
.await;
}
ActiveInput::Control => continue,
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
@@ -271,6 +271,30 @@ pub(super) async fn forward_execution(
}
}
async fn send_initial_engine_error(
connection: &mut ResponsesWebSocketConnection,
execution: &mut PendingExecution,
error: &EngineError,
request_id: &Arc<str>,
) -> ForwardOutcome {
let response_headers = execution
.session_mut()
.map(|session| session.response_headers().to_vec())
.unwrap_or_default();
if !response_headers.is_empty()
&& !send_metadata(
connection,
request_id,
response_metadata_event(request_id, &response_headers),
)
.await
{
return ForwardOutcome::Disconnect;
}
let error = gateway_error_from_engine(error);
send_gateway_error(connection, &error, request_id).await
}
fn commit_connection_replay(
replay: &mut ConnectionReplaySnapshot,
encoder: &OpenAiResponsesEncoder,
@@ -143,6 +143,8 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
);
let _connection_guard = connection_guard;
let cancellation = lifecycle.cancellation();
let request_headers =
request_headers.with_downstream_websocket_connection_id(connection_id.clone());
let mut connection = ResponsesWebSocketConnection::new(socket, connection_id, cancellation);
let mut request_count = 0_u64;
let mut replay = ConnectionReplaySnapshot::default();
@@ -43,7 +43,6 @@ fn decode_response_create_inner(
}
super::super::request::decode_request_object(
body,
false,
request_headers,
RequestDecodeSource::WebSocketFrame,
)
@@ -8,8 +8,9 @@ use axum::{
use super::{
images::{image_edits, image_generations},
models::{model_catalog, model_detail, model_info, models},
responses::{responses, responses_websocket, review_responses},
models::{model_detail, models},
responses::{responses, responses_websocket},
search::standalone_search,
};
use crate::ApiState;
@@ -19,11 +20,9 @@ pub(crate) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/images/generations", post(image_generations))
.route("/v1/images/edits", post(image_edits))
.route("/v1/alpha/search", post(standalone_search))
.route("/v1/responses", get(responses_websocket).post(responses))
.route("/v1/responses/review", post(review_responses))
.route("/v1/models", get(models))
.route("/v1/models/catalog", get(model_catalog))
.route("/v1/models/{model_id}/info", get(model_info))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
@@ -0,0 +1,78 @@
//! Codex standalone search 非流式 HTTP adapter。
use std::net::SocketAddr;
use axum::{
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::HeaderMap,
response::Response,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest};
use serde_json::{Map, Value};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::request_client_context,
};
const OPENAI_PROTOCOL: &str = "openai";
const TURN_METADATA_CONTEXT_KEY: &str = "turn_metadata";
/// `POST /v1/alpha/search`。
pub(crate) async fn standalone_search(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let operation = match search_operation(body, &headers) {
Ok(operation) => operation,
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_provider_endpoint(client, operation, client_ip, user_agent, "/v1/alpha/search")
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_raw_json_response(started).await
}
fn search_operation(body: Bytes, headers: &HeaderMap) -> Result<Operation, GatewayError> {
let mut context = Map::new();
if let Some(turn_metadata) = headers
.get("x-codex-turn-metadata")
.and_then(|value| value.to_str().ok())
{
context.insert(
TURN_METADATA_CONTEXT_KEY.to_owned(),
Value::String(turn_metadata.to_owned()),
);
}
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
)
})?
.with_context(context);
Ok(Operation::Search(StandaloneSearchRequest::from_raw_json(
payload,
)))
}
@@ -0,0 +1,5 @@
//! Codex standalone search HTTP adapter。
mod http;
pub(crate) use http::standalone_search;
@@ -106,7 +106,7 @@ impl OpenAiService {
.await
}
pub(crate) async fn start_image(
pub(crate) async fn start_provider_endpoint(
&self,
client: AuthenticatedClient,
operation: gateway_core::operation::Operation,
@@ -348,6 +348,8 @@ mod response {
output_tokens_display: "-".to_owned(),
cached_tokens: None,
cached_tokens_display: "-".to_owned(),
reasoning_tokens: None,
reasoning_tokens_display: "-".to_owned(),
image_input_tokens: None,
image_input_tokens_display: "-".to_owned(),
image_output_tokens: None,
@@ -374,6 +376,8 @@ mod response {
let value = serde_json::to_value(view).expect("serialize account usage");
assert!(value["inputTokens"].is_null());
assert!(value["totalTokens"].is_null());
assert!(value["reasoningTokens"].is_null());
assert_eq!(value["reasoningTokensDisplay"], "-");
assert_eq!(value["createdTokensDisplay"], "-");
}
@@ -49,6 +49,12 @@ fn ops_query_should_reject_page_size_above_terminal_limit() {
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
}
#[test]
fn ops_query_should_reject_removed_filters() {
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
@@ -571,6 +571,18 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
request_kind: Some("root".to_owned()),
subagent_kind: None,
compact: Some(false),
continuation_affinity_hash: Some("affinity-hash".to_owned()),
continuation_previous_response_id_hash: Some("response-hash".to_owned()),
continuation_unavailable_reason: Some("reused_connection_lost".to_owned()),
upstream_connection_id: Some("connection-id".to_owned()),
upstream_connection_exit_reason: Some("tcp_reset".to_owned()),
upstream_connection_age_ms: Some(12_000),
upstream_connection_idle_ms: Some(4_000),
recovery_request_id: None,
recovered_at: None,
recovery_attempt_count: 0,
recovery_retry_delay_ms: None,
recovery_total_latency_ms: None,
occurred_at: Utc::now(),
stable_sort_id: "model_request:req_err".to_owned(),
});
@@ -607,6 +619,9 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"providerErrorCode": value["data"]["items"][0]["providerErrorCode"],
"upstreamSendState": value["data"]["items"][0]["upstreamSendState"],
"rawUpstreamError": value["data"]["items"][0]["rawUpstreamError"],
"continuationUnavailableReason": value["data"]["items"][0]["metadata"]["continuationUnavailableReason"],
"upstreamConnectionExitReason": value["data"]["items"][0]["metadata"]["upstreamConnectionExitReason"],
"upstreamConnectionAgeMs": value["data"]["items"][0]["metadata"]["upstreamConnectionAgeMs"],
}),
serde_json::json!({
"provider": "openai",
@@ -623,6 +638,9 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"providerErrorCode": "upstream",
"upstreamSendState": "sent",
"rawUpstreamError": "{\"error\":{\"code\":\"upstream\",\"message\":\"raw upstream marker\"}}",
"continuationUnavailableReason": "reused_connection_lost",
"upstreamConnectionExitReason": "tcp_reset",
"upstreamConnectionAgeMs": 12000,
})
);
}
@@ -61,6 +61,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/images/mod.rs",
"src/openai/mod.rs",
"src/openai/models.rs",
"src/openai/provider_endpoint.rs",
"src/openai/responses/error.rs",
"src/openai/responses/http.rs",
"src/openai/responses/mod.rs",
@@ -71,6 +72,8 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/responses/websocket/mod.rs",
"src/openai/responses/websocket/protocol.rs",
"src/openai/router.rs",
"src/openai/search/http.rs",
"src/openai/search/mod.rs",
"src/openai/service.rs",
];
expected.sort_unstable();
@@ -104,12 +107,14 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/openai/images/mod.rs",
"tests/openai/mod.rs",
"tests/openai/models.rs",
"tests/openai/provider_endpoint.rs",
"tests/openai/responses/http.rs",
"tests/openai/responses/mod.rs",
"tests/openai/responses/websocket/connection.rs",
"tests/openai/responses/websocket/mod.rs",
"tests/openai/responses/websocket/protocol.rs",
"tests/openai/router.rs",
"tests/openai/search/mod.rs",
];
expected.sort_unstable();
@@ -398,3 +398,38 @@ async fn gateway_error_response_should_expose_only_structured_client_visible_ups
})
);
}
#[tokio::test]
async fn continuation_recovery_should_preserve_the_official_retry_signal() {
let error = GatewayError::from_provider(
&ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Previous response was not found. Retrying the full request.",
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
)),
);
let response = gateway_error_response(&error);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read continuation recovery response");
let body: serde_json::Value =
serde_json::from_slice(&body).expect("continuation recovery JSON");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body,
serde_json::json!({
"error": {
"message": "Previous response was not found. Retrying the full request.",
"type": "invalid_request_error",
"code": "previous_response_not_found"
}
})
);
}
@@ -2,8 +2,10 @@ mod auth;
mod error;
mod images;
mod models;
mod provider_endpoint;
mod responses;
mod router;
mod search;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -383,46 +383,6 @@ async fn model_detail_should_hide_unknown_models() {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn model_catalog_should_keep_the_codex_catalog_contract() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/catalog"))
.await
.expect("catalog response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read catalog body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("catalog JSON");
assert_eq!(value[0]["id"], "model-a");
assert_eq!(value[0]["displayName"], "model-a");
assert_eq!(value[0]["source"], "gateway");
}
#[tokio::test]
async fn model_info_should_return_a_single_visible_catalog_entry() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-a/info"))
.await
.expect("model info response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn model_info_should_hide_unknown_models() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-private/info"))
.await
.expect("missing model info response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn models_should_authenticate_before_applying_the_version_gate() {
let response = api_router(ModelsExecution::with_cli_min())
@@ -0,0 +1,107 @@
use std::sync::{Arc, Mutex, atomic::AtomicBool, atomic::Ordering};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::execution::ExecutionSession;
use gateway_core::engine::{CoordinatedEvent, EngineError};
use gateway_core::error::ProviderError;
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
pub(super) struct BufferedJsonSession {
body: Option<Bytes>,
response_headers: Vec<ProviderResponseHeader>,
response_status: Option<u16>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
finalized: AtomicBool,
failure: Option<ProviderError>,
}
impl BufferedJsonSession {
pub(super) fn success(
body: Bytes,
response_status: u16,
response_headers: Vec<ProviderResponseHeader>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
) -> Self {
Self {
body: Some(body),
response_headers,
response_status: Some(response_status),
committed_statuses,
finalized: AtomicBool::new(false),
failure: None,
}
}
pub(super) fn failure(error: ProviderError) -> Self {
Self {
body: None,
response_headers: Vec::new(),
response_status: None,
committed_statuses: Arc::new(Mutex::new(Vec::new())),
finalized: AtomicBool::new(false),
failure: Some(error),
}
}
}
impl ExecutionSession for BufferedJsonSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async { unreachable!("buffered JSON delivery does not stream") })
}
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
Box::pin(async move {
if let Some(error) = self.failure.take() {
return Err(EngineError::Provider(error));
}
Ok(vec![ProviderEvent::wire(
ProtocolWireEvent::raw_json(
"openai",
self.body.take().expect("single buffered JSON response"),
)
.expect("OpenAI protocol"),
)])
})
}
fn response_headers(&self) -> &[ProviderResponseHeader] {
&self.response_headers
}
fn response_status_code(&self) -> Option<u16> {
self.response_status
}
fn commit_downstream(
&mut self,
client_status_code: Option<u16>,
) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async move {
self.committed_statuses
.lock()
.expect("committed status lock")
.push(client_status_code.expect("buffered JSON response status"));
self.finalized.store(true, Ordering::Release);
Ok(())
})
}
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async { Ok(()) })
}
fn is_finalized(&self) -> bool {
self.finalized.load(Ordering::Acquire)
}
fn cancel(&self) {
self.finalized.store(true, Ordering::Release);
}
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
Box::pin(async move {
self.finalized.store(true, Ordering::Release);
})
}
}
@@ -36,7 +36,7 @@ use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
use tower::ServiceExt;
use crate::openai::{api_router, authenticated_client, authenticated_client_for_provider};
use crate::openai::{api_router, authenticated_client_for_provider};
#[derive(Default)]
struct Trace {
@@ -689,92 +689,6 @@ async fn xai_private_headers_should_not_enter_openai_request_facts() {
assert!(captured.prompt_cache_key.is_none());
}
#[tokio::test]
async fn review_route_should_inject_review_subagent_and_record_its_endpoint() {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: authenticated_client("sk_context_test"),
});
let request = Request::post("/v1/responses/review")
.header(AUTHORIZATION, "Bearer sk_context_test")
.header("x-openai-subagent", "compact")
.body(Body::from(
json!({
"model": "smart-code",
"input": "review this",
"client_metadata": {"existing": "preserved"}
})
.to_string(),
))
.expect("review request");
let response = api_router(execution)
.await
.oneshot(request)
.await
.expect("review response");
let captured = observed
.lock()
.expect("review capture lock")
.clone()
.expect("captured review request");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(captured.endpoint, "/v1/responses/review");
assert_eq!(
captured
.client_metadata
.as_ref()
.and_then(|metadata| metadata.get("x-openai-subagent")),
Some(&json!("review"))
);
assert_eq!(
captured
.client_metadata
.as_ref()
.and_then(|metadata| metadata.get("existing")),
Some(&json!("preserved"))
);
}
#[tokio::test]
async fn review_route_should_not_replace_non_object_client_metadata() {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: authenticated_client("sk_context_test"),
});
let request = Request::post("/v1/responses/review")
.header(AUTHORIZATION, "Bearer sk_context_test")
.body(Body::from(
json!({
"model": "smart-code",
"input": "review this",
"client_metadata": ["opaque", "client", "value"]
})
.to_string(),
))
.expect("review request");
let response = api_router(execution)
.await
.oneshot(request)
.await
.expect("review response");
let captured = observed
.lock()
.expect("review capture lock")
.clone()
.expect("captured review request");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
captured.client_metadata,
Some(json!(["opaque", "client", "value"]))
);
}
#[tokio::test]
async fn streaming_encodes_first_frame_before_commit_and_http_delivery() {
let trace = Arc::new(Trace::default());
@@ -979,6 +893,41 @@ async fn streaming_rate_limit_before_first_frame_should_persist_the_returned_429
assert_eq!(trace.client_statuses(), vec![429]);
}
#[tokio::test]
async fn streaming_failure_before_first_frame_should_return_safe_observed_headers() {
let trace = Arc::new(Trace::default());
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::Ambiguous,
)))],
)
.with_response_headers(vec![
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-before-close"),
),
ProviderResponseHeader::new(
"authorization",
Bytes::from_static(b"must-not-cross-boundary"),
),
]);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-before-close")
);
assert!(response.headers().get("authorization").is_none());
assert_eq!(trace.client_statuses(), vec![502]);
}
#[tokio::test]
async fn streaming_upstream_http_failure_before_first_frame_should_preserve_raw_response() {
let trace = Arc::new(Trace::default());
@@ -1065,11 +1014,29 @@ async fn buffered_upstream_http_failure_should_preserve_raw_response() {
raw_body.clone(),
));
let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()])
.with_collect_error(EngineError::Provider(error));
.with_collect_error(EngineError::Provider(error))
.with_response_headers(vec![
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-before-close"),
),
ProviderResponseHeader::new(
"authorization",
Bytes::from_static(b"must-not-cross-boundary"),
),
]);
let response = collect_execution_response(Box::new(session)).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-before-close")
);
assert!(response.headers().get("authorization").is_none());
assert_eq!(
to_bytes(response.into_body(), usize::MAX)
.await
@@ -428,6 +428,7 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
response_headers: Vec::new(),
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -2,7 +2,7 @@ mod connection;
mod protocol;
use std::sync::{
Arc,
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::{Duration, SystemTime};
@@ -280,17 +280,26 @@ struct AtomicFailureTrace {
next_calls: AtomicUsize,
committed: AtomicBool,
finalized: AtomicBool,
downstream_websocket_connection_ids: Mutex<Vec<String>>,
}
struct AtomicFailureSession {
trace: Arc<AtomicFailureTrace>,
response_headers: Vec<ProviderResponseHeader>,
fail_before_first_event: bool,
}
impl ExecutionSession for AtomicFailureSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async move {
match self.trace.next_calls.fetch_add(1, Ordering::AcqRel) {
let next_call = self.trace.next_calls.fetch_add(1, Ordering::AcqRel);
if self.fail_before_first_event && next_call == 0 {
return Err(EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::Ambiguous,
)));
}
match next_call {
0 => Ok(Some(atomic_failure_batch())),
1 => {
self.trace.finalized.store(true, Ordering::Release);
@@ -340,6 +349,7 @@ struct AtomicFailureExecution {
client: AuthenticatedClient,
trace: Arc<AtomicFailureTrace>,
response_headers: Vec<ProviderResponseHeader>,
fail_before_first_event: bool,
}
impl ExecutionService for AtomicFailureExecution {
@@ -365,6 +375,19 @@ impl ExecutionService for AtomicFailureExecution {
request: StartExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
self.trace.starts.fetch_add(1, Ordering::AcqRel);
if let Operation::Generate(generate) = &request.operation
&& let Some(connection_id) = generate
.protocol_payload()
.context()
.get("downstream_websocket_connection_id")
.and_then(Value::as_str)
{
self.trace
.downstream_websocket_connection_ids
.lock()
.expect("downstream connection trace lock")
.push(connection_id.to_owned());
}
Box::pin(async move {
Ok(StartedExecution {
request_id: gateway_core::engine::ModelRequestId::new("req_ws_atomic")
@@ -374,6 +397,7 @@ impl ExecutionService for AtomicFailureExecution {
session: Box::new(AtomicFailureSession {
trace: Arc::clone(&self.trace),
response_headers: self.response_headers.clone(),
fail_before_first_event: self.fail_before_first_event,
}),
})
})
@@ -439,6 +463,7 @@ async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
response_headers: Vec::new(),
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -510,6 +535,14 @@ async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
assert!(trace.committed.load(Ordering::Acquire));
assert_eq!(trace.next_calls.load(Ordering::Acquire), 2);
assert!(trace.finalized.load(Ordering::Acquire));
{
let downstream_connection_ids = trace
.downstream_websocket_connection_ids
.lock()
.expect("downstream connection trace lock");
assert_eq!(downstream_connection_ids.len(), 1);
assert!(downstream_connection_ids[0].starts_with("ws_"));
}
socket.close(None).await.expect("close WebSocket");
server.abort();
@@ -540,6 +573,7 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
ProviderResponseHeader::new("content-type", Bytes::from_static(b"application/private")),
ProviderResponseHeader::new("x-request-id", Bytes::from_static(b"req_upstream")),
],
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -624,6 +658,91 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
server.abort();
}
#[tokio::test]
async fn websocket_failure_before_first_event_should_return_observed_metadata_then_error() {
let trace = Arc::new(AtomicFailureTrace::default());
let execution = Arc::new(AtomicFailureExecution {
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
response_headers: vec![
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-before-close"),
),
ProviderResponseHeader::new(
"authorization",
Bytes::from_static(b"must-not-cross-boundary"),
),
],
fail_before_first_event: true,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind WebSocket test server");
let address = listener.local_addr().expect("test server address");
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("serve WebSocket test router");
});
let mut request = format!("ws://{address}/v1/responses")
.into_client_request()
.expect("WebSocket request");
request.headers_mut().insert(
AUTHORIZATION,
"Bearer sk_ws_atomic".parse().expect("authorization"),
);
let (mut socket, response) = connect_async(request).await.expect("upgrade WebSocket");
assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS);
socket
.send(ClientMessage::Text(
json!({
"type": "response.create",
"model": "model-a",
"input": "hello"
})
.to_string()
.into(),
))
.await
.expect("send response.create");
let mut messages = Vec::<Value>::new();
while messages.len() < 2 {
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("WebSocket response timeout")
.expect("WebSocket remains open")
.expect("valid WebSocket frame");
let ClientMessage::Text(text) = message else {
continue;
};
messages.push(serde_json::from_str(&text).expect("JSON WebSocket event"));
}
assert_eq!(
messages
.iter()
.filter_map(|message| message.get("type").and_then(Value::as_str))
.collect::<Vec<_>>(),
vec!["response.metadata", "error"]
);
let headers = messages[0]
.get("headers")
.and_then(Value::as_object)
.expect("response metadata headers");
assert_eq!(
headers.get("x-codex-turn-state"),
Some(&json!("turn-state-before-close"))
);
assert!(!headers.contains_key("authorization"));
assert!(!trace.committed.load(Ordering::Acquire));
socket.close(None).await.expect("close WebSocket");
server.abort();
}
#[tokio::test]
async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit() {
let trace = Arc::new(AtomicFailureTrace::default());
@@ -631,6 +750,7 @@ async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit(
client: authenticated_client("sk_ws_atomic"),
trace,
response_headers: Vec::new(),
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -1,6 +1,6 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode},
http::{Method, Request, StatusCode, header::AUTHORIZATION},
};
use tower::ServiceExt;
@@ -9,6 +9,54 @@ use super::models::ModelsExecution;
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
#[tokio::test]
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.oneshot(
Request::post("/v1/responses/review")
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build removed review request"),
)
.await
.expect("route removed review request");
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn removed_models_catalog_extension_should_use_standard_model_lookup() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.oneshot(
Request::get("/v1/models/catalog")
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build removed model catalog request"),
)
.await
.expect("route removed model catalog request");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn removed_model_info_route_should_return_not_found() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.oneshot(
Request::get("/v1/models/model-a/info")
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build removed model info request"),
)
.await
.expect("route removed model info request");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn responses_body_should_accept_payload_above_the_removed_private_limit() {
let router = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
@@ -0,0 +1,229 @@
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::ModelRequestId;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::error::{
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
};
use gateway_core::event::ProviderResponseHeader;
use gateway_core::operation::Operation;
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use tower::ServiceExt;
use super::provider_endpoint::BufferedJsonSession;
use super::{api_router, authenticated_client};
const SEARCH_RESPONSE: &[u8] = br#"{ "encrypted_output":"ciphertext", "output":"search result", "results":[{"type":"text_result","ref_id":"turn0search0","future":9007199254740993}] }"#;
#[derive(Debug, Clone, PartialEq)]
struct CapturedSearchRequest {
provider: String,
endpoint: String,
transport: ClientTransport,
body: Bytes,
context: Value,
}
struct SearchExecution {
client: AuthenticatedClient,
captured: Mutex<Vec<CapturedSearchRequest>>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
fail_with_upstream_response: bool,
}
impl SearchExecution {
fn new(fail_with_upstream_response: bool) -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_search_test"),
captured: Mutex::new(Vec::new()),
committed_statuses: Arc::new(Mutex::new(Vec::new())),
fail_with_upstream_response,
})
}
fn captured(&self) -> Vec<CapturedSearchRequest> {
self.captured.lock().expect("capture lock").clone()
}
}
impl ExecutionService for SearchExecution {
fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
(plaintext == "sk_search_test")
.then(|| self.client.clone())
.ok_or(ClientAuthenticationError::InvalidKey)
}
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
Vec::new()
}
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
false
}
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"search test must use provider endpoint execution",
))
})
}
fn start_provider_endpoint(
&self,
request: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move {
let Operation::Search(search) = &request.operation else {
return Err(GatewayError::new(
GatewayErrorKind::Internal,
"search test received a non-search operation",
));
};
let payload = search.payload();
self.captured
.lock()
.expect("capture lock")
.push(CapturedSearchRequest {
provider: request.provider.as_str().to_owned(),
endpoint: request.metadata.endpoint,
transport: request.metadata.transport,
body: payload.body().clone(),
context: Value::Object(payload.context().clone()),
});
let session = if self.fail_with_upstream_response {
BufferedJsonSession::failure(
ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::Sent,
)
.with_status(StatusCode::UNPROCESSABLE_ENTITY.as_u16())
.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
Some(b"application/problem+json".to_vec()),
Bytes::from_static(
br#"{ "error":{"message":"future search validation"}, "future":9007199254740993 }"#,
),
)
.with_headers(vec![ProviderResponseHeader::new(
"x-future-search-error",
Bytes::from_static(b"preserved"),
)]),
),
)
} else {
BufferedJsonSession::success(
Bytes::from_static(SEARCH_RESPONSE),
StatusCode::CREATED.as_u16(),
vec![ProviderResponseHeader::new(
"x-search-rate-limit",
Bytes::from_static(b"42"),
)],
Arc::clone(&self.committed_statuses),
)
};
Ok(StartedExecution {
request_id: ModelRequestId::new("req_search_test").expect("request ID"),
created_at: SystemTime::now(),
stream: false,
session: Box::new(session),
})
})
}
}
#[tokio::test]
async fn search_route_should_preserve_request_and_success_response_bytes() {
let execution = SearchExecution::new(false);
let request_body = br#"{ "id":"search-session", "model":"gpt-future", "commands":{"search_query":[{"q":"private query"}]}, "future":1, "future":2 }"#;
let turn_metadata =
r#"{"session_id":"session","thread_id":"thread","turn_id":"turn","future":true}"#;
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/alpha/search")
.header(AUTHORIZATION, "Bearer sk_search_test")
.header("content-type", "application/json")
.header("x-codex-turn-metadata", turn_metadata)
.body(Body::from(request_body.to_vec()))
.expect("search request"),
)
.await
.expect("search response");
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(
response.headers().get("x-search-rate-limit"),
Some(&"42".parse().expect("header value"))
);
let response_body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read search response");
assert_eq!(response_body.as_ref(), SEARCH_RESPONSE);
let captured = execution.captured();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].provider, "openai");
assert_eq!(captured[0].endpoint, "/v1/alpha/search");
assert_eq!(captured[0].transport, ClientTransport::HttpJson);
assert_eq!(captured[0].body.as_ref(), request_body);
assert_eq!(captured[0].context, json!({"turn_metadata": turn_metadata}));
assert_eq!(
*execution
.committed_statuses
.lock()
.expect("committed status lock"),
vec![StatusCode::CREATED.as_u16()]
);
}
#[tokio::test]
async fn search_route_should_return_the_exact_upstream_error_response() {
let response = api_router(SearchExecution::new(true))
.await
.oneshot(
Request::post("/v1/alpha/search")
.header(AUTHORIZATION, "Bearer sk_search_test")
.header("content-type", "application/json")
.body(Body::from(
br#"{ "model":"gpt-future", "future_invalid":9007199254740993 }"#.to_vec(),
))
.expect("search request"),
)
.await
.expect("search response");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
response.headers().get("content-type"),
Some(&"application/problem+json".parse().expect("content type"))
);
assert_eq!(
response.headers().get("x-future-search-error"),
Some(&"preserved".parse().expect("header value"))
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read search response");
assert_eq!(
body.as_ref(),
br#"{ "error":{"message":"future search validation"}, "future":9007199254740993 }"#
);
}
@@ -3,9 +3,9 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap};
use std::num::NonZeroU32;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
use std::sync::{Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use crate::routing::ProviderKind;
@@ -94,6 +94,7 @@ pub struct AccountRuntimeSignals {
}
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
/// 一次真实上游 attempt 对账号级 Smart 调度产生的中立反馈。
@@ -111,37 +112,69 @@ struct AccountFeedbackKey {
#[derive(Debug)]
struct AccountFeedback {
failure_rate: AtomicU64,
failure_rate: Mutex<DecayingAccountFailureRate>,
first_output_ms: AtomicU64,
}
#[derive(Debug, Default)]
struct DecayingAccountFailureRate {
value: f64,
updated_at: Option<Instant>,
}
impl DecayingAccountFailureRate {
fn value_at(&self, now: Instant) -> f64 {
let Some(updated_at) = self.updated_at else {
return self.value;
};
let elapsed = now.checked_duration_since(updated_at).unwrap_or_default();
self.value
* 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64())
}
fn report_at(&mut self, sample: f64, now: Instant) {
let now = self
.updated_at
.map_or(now, |updated_at| updated_at.max(now));
let decayed = self.value_at(now);
self.value =
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed;
self.updated_at = Some(now);
}
}
impl Default for AccountFeedback {
fn default() -> Self {
Self {
// 新账号从健康基线开始,首个失败样本按 EWMA 平滑,而不是直接判死。
failure_rate: AtomicU64::new(0.0_f64.to_bits()),
failure_rate: Mutex::new(DecayingAccountFailureRate::default()),
first_output_ms: AtomicU64::new(EMPTY_FEEDBACK_SAMPLE),
}
}
}
impl AccountFeedback {
fn report(&self, feedback: AccountAttemptFeedback) {
fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) {
let (failure, first_output_ms) = match feedback {
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
};
update_feedback_ewma(&self.failure_rate, failure);
self.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.report_at(failure, now);
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
}
}
fn scheduling_signals(&self) -> (Option<u16>, Option<u64>) {
let failure_rate = load_feedback_ewma(&self.failure_rate).map(|value| {
let basis_points = (value.clamp(0.0, 1.0) * 10_000.0).round();
basis_points as u16
});
fn scheduling_signals_at(&self, now: Instant) -> (Option<u16>, Option<u64>) {
let failure_rate = self
.failure_rate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.value_at(now);
let failure_rate = Some((failure_rate.clamp(0.0, 1.0) * 10_000.0).round() as u16);
let first_output_ms = load_feedback_ewma(&self.first_output_ms)
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round() as u64);
@@ -162,6 +195,18 @@ impl AccountFeedbackStats {
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
) -> (Option<u16>, Option<u64>) {
self.scheduling_signals_at(provider_kind, account_id, Instant::now())
}
/// 读取账号在指定单调时刻的错误率与首个有效输出延迟 EWMA。
#[doc(hidden)]
#[must_use]
pub fn scheduling_signals_at(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
now: Instant,
) -> (Option<u16>, Option<u64>) {
let key = AccountFeedbackKey {
provider_kind: provider_kind.clone(),
@@ -171,7 +216,7 @@ impl AccountFeedbackStats {
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
.map(AccountFeedback::scheduling_signals)
.map(|account| account.scheduling_signals_at(now))
.unwrap_or_default()
}
@@ -181,6 +226,18 @@ impl AccountFeedbackStats {
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
feedback: AccountAttemptFeedback,
) {
self.report_at(provider_kind, account_id, feedback, Instant::now());
}
/// 回灌一次在指定单调时刻真实发送的上游 attempt。
#[doc(hidden)]
pub fn report_at(
&self,
provider_kind: &ProviderKind,
account_id: &ProviderAccountId,
feedback: AccountAttemptFeedback,
observed_at: Instant,
) {
let key = AccountFeedbackKey {
provider_kind: provider_kind.clone(),
@@ -192,7 +249,7 @@ impl AccountFeedbackStats {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
{
account.report(feedback);
account.report_at(feedback, observed_at);
return;
}
self.accounts
@@ -200,7 +257,7 @@ impl AccountFeedbackStats {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(key)
.or_default()
.report(feedback);
.report_at(feedback, observed_at);
}
}
@@ -13,9 +13,9 @@ use crate::engine::provider::{Provider, ProviderCallMetadata, ProviderRequest, P
use crate::engine::{
AccountAttemptContext, AttemptContext, AttemptRecord, AttemptTransport, AttemptTrigger,
CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError, ExecutionOutcome,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
ModelRequestTimings, NewModelRequest, ProviderAccountStateOwner, ProviderAttemptOutcome,
RequestAttemptContext, UpstreamSendState,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFailureObservation,
ModelRequestFinalization, ModelRequestId, ModelRequestTimings, NewModelRequest,
ProviderAccountStateOwner, ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState,
};
use crate::error::{
ContinuationRecoveryDisposition, GatewayError, GatewayErrorKind, ProviderError,
@@ -221,6 +221,7 @@ struct FailureFinalization {
provider_error_code: Option<String>,
raw_upstream_error: Option<String>,
retry_after_ms: Option<u64>,
observation: ModelRequestFailureObservation,
}
#[derive(Debug, Clone)]
@@ -638,6 +639,7 @@ where
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::EmptyRoutingPlan);
@@ -795,6 +797,7 @@ where
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::ProviderMetadataMismatch);
@@ -820,6 +823,7 @@ where
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::AccountOutsideClientScope);
@@ -840,6 +844,7 @@ where
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::RequiredAccountMismatch);
@@ -863,6 +868,7 @@ where
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await?;
return Err(EngineError::ContinuationPinMismatch);
@@ -1368,6 +1374,7 @@ where
error: None,
provider_error_code: None,
raw_upstream_error: None,
failure_observation: ModelRequestFailureObservation::default(),
retry_after_ms: None,
usage: self.usage.clone(),
image_generation_succeeded: self.image_generation_succeeded(),
@@ -1410,6 +1417,12 @@ where
.raw_upstream_error()
.map(|raw| raw.as_str().to_owned()),
retry_after_ms: error.retry_after().map(duration_ms),
observation: ModelRequestFailureObservation {
continuation_unavailable_reason: error
.continuation_unavailable_reason()
.map(str::to_owned),
upstream_connection: error.connection_observation().cloned(),
},
})
.await
}
@@ -1464,6 +1477,7 @@ where
provider_error_code: None,
raw_upstream_error: None,
retry_after_ms: None,
observation: ModelRequestFailureObservation::default(),
})
.await
}
@@ -1532,6 +1546,7 @@ where
error: Some(finalization.error),
provider_error_code: finalization.provider_error_code,
raw_upstream_error: finalization.raw_upstream_error,
failure_observation: finalization.observation,
retry_after_ms: finalization.retry_after_ms,
usage: self.usage.clone(),
image_generation_succeeded: self.image_generation_succeeded(),
@@ -505,8 +505,11 @@ impl DefaultExecutionService {
.candidates()
.first()
.map_or_else(Default::default, |candidate| {
self.providers
.request_observation(candidate.provider(), &operation)
self.providers.request_observation(
candidate.provider(),
&operation,
client.policy.key_id(),
)
});
let new_request = NewModelRequest {
id: request_id.clone(),
@@ -526,6 +529,7 @@ impl DefaultExecutionService {
request_kind: observation.request_kind,
subagent_kind: observation.subagent_kind,
compact: observation.compact,
continuation: observation.continuation,
image_generation_requested: operation.image_generation_requested(),
admission_decision_ms: Some(admission_decision_ms),
started_at,
@@ -668,6 +672,7 @@ impl DefaultExecutionService {
request_kind: Some("account_connection_test".to_owned()),
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: false,
admission_decision_ms: None,
started_at,
+11 -1
View File
@@ -22,7 +22,8 @@ use thiserror::Error;
use crate::account::{AccountSelectionPolicy, ProviderAccountId};
use crate::engine::continuation::{ContinuationBinding, NativeContinuationPin};
use crate::error::{
GatewayError, IdentifierError, ProviderError, ProviderErrorKind, StoreError, validate_text,
GatewayError, IdentifierError, ProviderConnectionObservation, ProviderError, ProviderErrorKind,
StoreError, validate_text,
};
use crate::event::ProviderEvent;
use crate::lifecycle::CancellationToken;
@@ -514,6 +515,7 @@ pub struct NewModelRequest {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub continuation: provider::ContinuationRequestObservation,
pub image_generation_requested: bool,
/// Client Key 准入判定的完整耗时;内部探测不经过该阶段。
pub admission_decision_ms: Option<u64>,
@@ -579,6 +581,13 @@ pub struct ModelRequestTimings {
pub latency_ms: Option<u64>,
}
/// 终态失败的 Provider 结构化连接与 continuation 观测。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ModelRequestFailureObservation {
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection: Option<ProviderConnectionObservation>,
}
/// 单行模型请求的终态写回。
#[derive(Debug)]
pub struct ModelRequestFinalization {
@@ -602,6 +611,7 @@ pub struct ModelRequestFinalization {
pub provider_error_code: Option<String>,
/// Provider 返回的原始错误正文或 WebSocket close/error frame。
pub raw_upstream_error: Option<String>,
pub failure_observation: ModelRequestFailureObservation,
pub retry_after_ms: Option<u64>,
pub usage: Usage,
pub image_generation_succeeded: Option<bool>,
@@ -15,9 +15,10 @@ use crate::account::{
AccountAttemptFeedback, AccountCapacitySnapshot, AccountFeedbackStats, ProviderAccountId,
};
use crate::engine::AttemptContext;
use crate::error::{OpaqueUpstreamValue, ProviderError, ProviderErrorKind};
use crate::error::{OpaqueUpstreamValue, PreDeliveryRetry, ProviderError, ProviderErrorKind};
use crate::event::{EventSequenceValidator, ProviderEvent};
use crate::operation::Operation;
use crate::policy::ClientApiKeyId;
use crate::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
};
@@ -190,11 +191,16 @@ struct ProviderStreamAccountFeedback {
stats: Arc<AccountFeedbackStats>,
provider_kind: ProviderKind,
account_id: ProviderAccountId,
failure_filter: fn(&ProviderError) -> bool,
started_at: Option<Instant>,
first_output_ms: Option<u64>,
reported: bool,
}
fn score_all_confirmed_failures(_: &ProviderError) -> bool {
true
}
impl ProviderStreamAccountFeedback {
fn start(&mut self) {
self.started_at.get_or_insert_with(Instant::now);
@@ -243,6 +249,15 @@ impl ProviderStreamAccountFeedback {
error.kind(),
ProviderErrorKind::Cancelled | ProviderErrorKind::ProcessTerminated
)
|| matches!(
error.pre_delivery_retry(),
Some(
PreDeliveryRetry::SameAccountTransportRetry { .. }
| PreDeliveryRetry::SameAccountTransportFallback
)
)
|| error.retries_same_account()
|| !(self.failure_filter)(error)
{
return;
}
@@ -278,15 +293,35 @@ impl ProviderStream {
/// 让公共 stream 边界统一回灌账号成功率与首个有效输出延迟。
#[must_use]
pub fn with_account_feedback(mut self, stats: Arc<AccountFeedbackStats>) -> Self {
self.set_account_feedback(stats, score_all_confirmed_failures);
self
}
/// 使用 Provider 定义的闭集判断回灌账号成功率与首个有效输出延迟。
#[must_use]
pub fn with_filtered_account_feedback(
mut self,
stats: Arc<AccountFeedbackStats>,
failure_filter: fn(&ProviderError) -> bool,
) -> Self {
self.set_account_feedback(stats, failure_filter);
self
}
fn set_account_feedback(
&mut self,
stats: Arc<AccountFeedbackStats>,
failure_filter: fn(&ProviderError) -> bool,
) {
self.account_feedback = Some(ProviderStreamAccountFeedback {
stats,
provider_kind: self.metadata.provider().clone(),
account_id: self.metadata.provider_account_id().clone(),
failure_filter,
started_at: None,
first_output_ms: None,
reported: false,
});
self
}
/// 返回调用事实。
@@ -387,6 +422,15 @@ pub struct ProviderRequestObservation {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub continuation: ContinuationRequestObservation,
}
/// 仅用于恢复事件关联的客户端作用域不透明请求事实。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ContinuationRequestObservation {
pub affinity_hash: Option<String>,
pub previous_response_id_hash: Option<String>,
pub requested: bool,
}
/// Provider 实时目录编译后的单模型能力。
@@ -495,7 +539,11 @@ pub trait Provider: Send + Sync {
fn catalog_generation(&self) -> ProviderCatalogGeneration;
/// 解释 Provider 差异化观测字段;不参与路由和传输。
fn request_observation(&self, _operation: &Operation) -> ProviderRequestObservation {
fn request_observation(
&self,
_operation: &Operation,
_client_api_key_id: &ClientApiKeyId,
) -> ProviderRequestObservation {
ProviderRequestObservation::default()
}
@@ -623,11 +671,12 @@ impl ProviderRegistry {
&self,
provider: &ProviderKind,
operation: &Operation,
client_api_key_id: &ClientApiKeyId,
) -> ProviderRequestObservation {
self.providers
.get(provider)
.map_or_else(ProviderRequestObservation::default, |registered| {
registered.request_observation(operation)
registered.request_observation(operation, client_api_key_id)
})
}
+108
View File
@@ -135,6 +135,8 @@ pub enum MeteringError {
pub enum ProviderErrorKind {
/// 请求语义错误。
InvalidRequest,
/// previous-response 续接状态已不可用,需要客户端重建请求链。
ContinuationRecoveryRequired,
/// Provider 不支持所需能力。
Unsupported,
/// Credential 认证失败。
@@ -171,6 +173,7 @@ impl ProviderErrorKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidRequest => "invalid_request",
Self::ContinuationRecoveryRequired => "continuation_recovery_required",
Self::Unsupported => "unsupported",
Self::Unauthorized => "unauthorized",
Self::PermissionDenied => "permission_denied",
@@ -357,6 +360,55 @@ impl fmt::Debug for RawUpstreamError {
}
}
/// Provider 上游物理连接结束时的低基数观测。
///
/// 连接标识必须是随机或单向派生值,不得携带凭据、原始会话 ID
/// 或请求正文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderConnectionObservation {
connection_id: String,
exit_reason: String,
age_ms: u64,
idle_ms: u64,
}
impl ProviderConnectionObservation {
#[must_use]
pub fn new(
connection_id: impl Into<String>,
exit_reason: impl Into<String>,
age_ms: u64,
idle_ms: u64,
) -> Self {
Self {
connection_id: connection_id.into(),
exit_reason: exit_reason.into(),
age_ms,
idle_ms,
}
}
#[must_use]
pub fn connection_id(&self) -> &str {
&self.connection_id
}
#[must_use]
pub fn exit_reason(&self) -> &str {
&self.exit_reason
}
#[must_use]
pub const fn age_ms(&self) -> u64 {
self.age_ms
}
#[must_use]
pub const fn idle_ms(&self) -> u64 {
self.idle_ms
}
}
/// 只供当前客户端请求使用的原始上游 HTTP 失败响应。
///
/// 该值不属于稳定诊断事实,不能进入日志或持久化。它刻意不实现 [`Clone`];
@@ -442,6 +494,7 @@ pub struct ProviderError {
retry_after: Option<Duration>,
continuation_failure: Option<ContinuationFailure>,
continuation_recovery_disposition: Option<ContinuationRecoveryDisposition>,
failure_observation: Option<Box<ProviderErrorFailureObservation>>,
replay_safe: bool,
pre_delivery_retry: Option<PreDeliveryRetry>,
credential_recovery_required: bool,
@@ -460,6 +513,12 @@ struct ProviderErrorUpstreamValues {
request_id: Option<OpaqueUpstreamValue>,
}
#[derive(Clone, Default)]
struct ProviderErrorFailureObservation {
continuation_unavailable_reason: Option<&'static str>,
connection: Option<ProviderConnectionObservation>,
}
#[derive(Clone)]
struct AtomicClientEvents(Vec<ProviderEvent>);
@@ -475,6 +534,7 @@ impl ProviderError {
retry_after: None,
continuation_failure: None,
continuation_recovery_disposition: None,
failure_observation: None,
replay_safe: false,
pre_delivery_retry: None,
credential_recovery_required: false,
@@ -540,6 +600,29 @@ impl ProviderError {
self
}
/// 附加 Provider 给出的低基数 continuation 不可用原因。
#[must_use]
pub fn with_continuation_unavailable_reason(mut self, reason: &'static str) -> Self {
self.failure_observation_mut()
.continuation_unavailable_reason = Some(reason);
self
}
/// 附加上游物理连接结束观测。
#[must_use]
pub fn with_connection_observation(
mut self,
observation: ProviderConnectionObservation,
) -> Self {
self.failure_observation_mut().connection = Some(observation);
self
}
fn failure_observation_mut(&mut self) -> &mut ProviderErrorFailureObservation {
self.failure_observation
.get_or_insert_with(|| Box::new(ProviderErrorFailureObservation::default()))
}
/// 标记 Provider 已证明本次拒绝没有执行生成,可在下游提交前重放。
#[must_use]
pub const fn with_replay_safe(mut self) -> Self {
@@ -714,6 +797,21 @@ impl ProviderError {
self.continuation_recovery_disposition
}
#[must_use]
pub fn continuation_unavailable_reason(&self) -> Option<&'static str> {
match self.failure_observation.as_deref() {
Some(observation) => observation.continuation_unavailable_reason,
None => None,
}
}
#[must_use]
pub fn connection_observation(&self) -> Option<&ProviderConnectionObservation> {
self.failure_observation
.as_deref()
.and_then(|observation| observation.connection.as_ref())
}
/// 返回 Provider 是否已证明本次失败可安全重放。
#[must_use]
pub const fn replay_is_safe(&self) -> bool {
@@ -801,6 +899,7 @@ impl Clone for ProviderError {
retry_after: self.retry_after,
continuation_failure: self.continuation_failure,
continuation_recovery_disposition: self.continuation_recovery_disposition,
failure_observation: self.failure_observation.clone(),
replay_safe: self.replay_safe,
pre_delivery_retry: self.pre_delivery_retry,
credential_recovery_required: self.credential_recovery_required,
@@ -836,6 +935,11 @@ impl fmt::Debug for ProviderError {
"continuation_recovery_disposition",
&self.continuation_recovery_disposition,
)
.field(
"continuation_unavailable_reason",
&self.continuation_unavailable_reason(),
)
.field("connection_observation", &self.connection_observation())
.field("replay_safe", &self.replay_safe)
.field("pre_delivery_retry", &self.pre_delivery_retry)
.field(
@@ -968,6 +1072,10 @@ impl GatewayError {
ProviderErrorKind::InvalidRequest => {
Self::new(GatewayErrorKind::InvalidRequest, "invalid upstream request")
}
ProviderErrorKind::ContinuationRecoveryRequired => Self::new(
GatewayErrorKind::InvalidRequest,
"conversation continuation must be rebuilt",
),
ProviderErrorKind::Unsupported => Self::new(
GatewayErrorKind::Unsupported,
"requested capability is unsupported",
@@ -22,6 +22,8 @@ pub enum OperationKind {
Generate,
/// 图像生成。
GenerateImage,
/// Provider 原生 standalone search。
Search,
}
impl OperationKind {
@@ -31,6 +33,7 @@ impl OperationKind {
match self {
Self::Generate => "generate",
Self::GenerateImage => "generate_image",
Self::Search => "search",
}
}
}
@@ -501,6 +504,35 @@ impl fmt::Debug for ImageRequest {
}
}
/// Provider 原生 standalone search 请求。
#[derive(Clone, PartialEq, Eq)]
pub struct StandaloneSearchRequest {
payload: RawJsonPayload,
}
impl StandaloneSearchRequest {
/// 创建携带原始协议 JSON 正文的 standalone search 请求。
#[must_use]
pub const fn from_raw_json(payload: RawJsonPayload) -> Self {
Self { payload }
}
/// 返回未经重编码的协议 JSON 正文。
#[must_use]
pub const fn payload(&self) -> &RawJsonPayload {
&self.payload
}
}
impl fmt::Debug for StandaloneSearchRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("StandaloneSearchRequest")
.field("payload", &"<not included in Debug>")
.finish()
}
}
/// 网关内部业务请求;不包含任何客户端 wire 或 Provider SDK 类型。
#[derive(Clone, PartialEq)]
#[non_exhaustive]
@@ -509,6 +541,8 @@ pub enum Operation {
Generate(GenerateRequest),
/// 图像生成。
GenerateImage(ImageRequest),
/// Provider 原生 standalone search。
Search(StandaloneSearchRequest),
}
impl Operation {
@@ -534,6 +568,7 @@ impl Operation {
match self {
Self::Generate(_) => OperationKind::Generate,
Self::GenerateImage(_) => OperationKind::GenerateImage,
Self::Search(_) => OperationKind::Search,
}
}
@@ -543,6 +578,7 @@ impl Operation {
match self {
Self::Generate(request) => request.requirements(),
Self::GenerateImage(_) => CapabilityRequirements::new(OperationKind::GenerateImage),
Self::Search(_) => CapabilityRequirements::new(OperationKind::Search),
}
}
@@ -552,6 +588,7 @@ impl Operation {
match self {
Self::Generate(request) => request.image_generation_requested(),
Self::GenerateImage(_) => true,
Self::Search(_) => false,
}
}
@@ -560,7 +597,7 @@ impl Operation {
pub fn provider_session_state(&self, provider: &str) -> Option<&ProviderSessionState> {
match self {
Self::Generate(request) => request.provider_session_state(provider),
Self::GenerateImage(_) => None,
Self::GenerateImage(_) | Self::Search(_) => None,
}
}
}
@@ -1,3 +1,5 @@
mod selection;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
use std::sync::Arc;
@@ -0,0 +1,98 @@
use std::time::{Duration, Instant};
use gateway_core::account::{AccountAttemptFeedback, AccountFeedbackStats, ProviderAccountId};
use gateway_core::routing::ProviderKind;
const FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
fn feedback_subject() -> (AccountFeedbackStats, ProviderKind, ProviderAccountId) {
(
AccountFeedbackStats::default(),
ProviderKind::new("openai").expect("valid provider"),
ProviderAccountId::new("acct_decay").expect("valid account"),
)
}
fn report_failure(
feedback: &AccountFeedbackStats,
provider: &ProviderKind,
account: &ProviderAccountId,
observed_at: Instant,
) {
feedback.report_at(
provider,
account,
AccountAttemptFeedback::Failed {
first_output_ms: None,
},
observed_at,
);
}
#[test]
fn account_failure_rate_should_halve_after_one_half_life() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
report_failure(&feedback, &provider, &account, observed_at);
let failure_rate = feedback
.scheduling_signals_at(&provider, &account, observed_at + FAILURE_RATE_HALF_LIFE)
.0;
assert_eq!(failure_rate, Some(1_000));
}
#[test]
fn account_failure_rate_should_quarter_after_two_half_lives() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
report_failure(&feedback, &provider, &account, observed_at);
let failure_rate = feedback
.scheduling_signals_at(
&provider,
&account,
observed_at + FAILURE_RATE_HALF_LIFE * 2,
)
.0;
assert_eq!(failure_rate, Some(500));
}
#[test]
fn account_failure_rate_should_decay_before_applying_a_new_sample() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
report_failure(&feedback, &provider, &account, observed_at);
feedback.report_at(
&provider,
&account,
AccountAttemptFeedback::Succeeded {
first_output_ms: None,
},
observed_at + FAILURE_RATE_HALF_LIFE,
);
let failure_rate = feedback
.scheduling_signals_at(&provider, &account, observed_at + FAILURE_RATE_HALF_LIFE)
.0;
assert_eq!(failure_rate, Some(800));
}
#[test]
fn concurrent_account_failures_should_not_lose_samples() {
let (feedback, provider, account) = feedback_subject();
let observed_at = Instant::now();
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| report_failure(&feedback, &provider, &account, observed_at));
}
});
let failure_rate = feedback
.scheduling_signals_at(&provider, &account, observed_at)
.0;
assert_eq!(failure_rate, Some(8_322));
}
@@ -501,10 +501,13 @@ fn atomic_history_unavailable(response_id: &str) -> ProviderError {
)
.expect("history unavailable wire"),
);
ProviderError::new(ProviderErrorKind::InvalidRequest, UpstreamSendState::Sent)
.with_status(400)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_atomic_client_events(vec![failed])
ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::Sent,
)
.with_status(400)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_atomic_client_events(vec![failed])
}
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
@@ -621,6 +624,7 @@ fn model_request(operation: &Operation, deadline: SystemTime) -> NewModelRequest
request_kind: Some("responses".to_owned()),
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: operation.image_generation_requested(),
started_at: SystemTime::now(),
deadline_at: deadline,
@@ -1591,7 +1595,7 @@ fn native_continuation_client_replay_required_is_terminal() {
Script::Stream {
account_id: "acct_one",
items: vec![Err(ProviderError::new(
ProviderErrorKind::InvalidRequest,
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
@@ -62,6 +62,9 @@ fn only_provider_attributable_failures_should_affect_circuit() {
assert!(!provider_failure_affects_circuit(
ProviderErrorKind::InvalidRequest
));
assert!(!provider_failure_affects_circuit(
ProviderErrorKind::ContinuationRecoveryRequired
));
}
#[test]
@@ -1,6 +1,8 @@
use std::collections::BTreeSet;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use futures::{StreamExt, stream};
@@ -244,3 +246,139 @@ fn provider_stream_should_report_confirmed_sent_failure_but_ignore_unconfirmed_f
(None, None)
);
}
#[test]
fn provider_stream_should_ignore_repeated_failures_rejected_by_the_filter() {
let feedback = Arc::new(AccountFeedbackStats::default());
let provider = ProviderKind::new("openai").expect("valid provider");
let account = ProviderAccountId::new("acct_client_config_error").expect("account");
for _ in 0..100 {
let metadata = ProviderCallMetadata::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("valid model"),
account.clone(),
UpstreamTransport::new("http_sse").expect("valid transport"),
);
let events: EventStream = Box::pin(stream::iter([Err(ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::Sent,
))]));
let mut provider_stream = ProviderStream::new(metadata, events, ())
.with_filtered_account_feedback(Arc::clone(&feedback), |_| false);
futures::executor::block_on(async {
assert!(
provider_stream
.next()
.await
.is_some_and(|event| event.is_err())
);
});
}
assert_eq!(
feedback.scheduling_signals(&provider, &account),
(None, None)
);
}
#[test]
fn provider_stream_should_score_only_the_final_same_account_failure() {
let feedback = Arc::new(AccountFeedbackStats::default());
let provider = ProviderKind::new("openai").expect("valid provider");
let account = ProviderAccountId::new("acct_same_account_failure").expect("account");
for retry_index in 1..=3 {
let metadata = ProviderCallMetadata::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("valid model"),
account.clone(),
UpstreamTransport::new("websocket").expect("valid transport"),
);
let error = ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Sent)
.with_pre_delivery_transport_retry(
NonZeroU32::new(retry_index).expect("positive retry index"),
Duration::ZERO,
);
let events: EventStream = Box::pin(stream::iter([Err(error)]));
let mut provider_stream = ProviderStream::new(metadata, events, ())
.with_filtered_account_feedback(Arc::clone(&feedback), |_| true);
futures::executor::block_on(async {
assert!(
provider_stream
.next()
.await
.is_some_and(|event| event.is_err())
);
});
}
let metadata = ProviderCallMetadata::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("valid model"),
account.clone(),
UpstreamTransport::new("websocket").expect("valid transport"),
);
let events: EventStream = Box::pin(stream::iter([Err(ProviderError::new(
ProviderErrorKind::Transport,
UpstreamSendState::Sent,
))]));
let mut provider_stream = ProviderStream::new(metadata, events, ())
.with_filtered_account_feedback(Arc::clone(&feedback), |_| true);
futures::executor::block_on(async {
assert!(
provider_stream
.next()
.await
.is_some_and(|event| event.is_err())
);
});
assert_eq!(
feedback.scheduling_signals(&provider, &account).0,
Some(2_000)
);
}
#[test]
fn provider_stream_should_report_one_success_after_same_account_retry() {
let feedback = Arc::new(AccountFeedbackStats::default());
let provider = ProviderKind::new("openai").expect("valid provider");
let account = ProviderAccountId::new("acct_same_account_success").expect("account");
let retry_metadata = ProviderCallMetadata::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("valid model"),
account.clone(),
UpstreamTransport::new("websocket").expect("valid transport"),
);
let retry_error = ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Sent)
.with_pre_delivery_transport_fallback();
let retry_events: EventStream = Box::pin(stream::iter([Err(retry_error)]));
let mut retry_stream = ProviderStream::new(retry_metadata, retry_events, ())
.with_filtered_account_feedback(Arc::clone(&feedback), |_| true);
futures::executor::block_on(async {
assert!(
retry_stream
.next()
.await
.is_some_and(|event| event.is_err())
);
});
let success_metadata = ProviderCallMetadata::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("valid model"),
account.clone(),
UpstreamTransport::new("http_sse").expect("valid transport"),
);
let success_events: EventStream = Box::pin(stream::empty());
let mut success_stream = ProviderStream::new(success_metadata, success_events, ())
.with_filtered_account_feedback(Arc::clone(&feedback), |_| true);
futures::executor::block_on(async {
assert!(success_stream.next().await.is_none());
});
assert_eq!(feedback.scheduling_signals(&provider, &account).0, Some(0));
}
@@ -168,6 +168,30 @@ fn gateway_error_should_keep_client_visible_upstream_fields_out_of_safe_diagnost
assert!(!format!("{gateway:?}").contains(message));
}
#[test]
fn continuation_recovery_should_keep_its_internal_classification_and_client_contract() {
let error = ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Previous response was not found. Retrying the full request.",
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
));
let gateway = GatewayError::from_provider(&error);
assert_eq!(error.kind().as_str(), "continuation_recovery_required");
assert_eq!(
gateway.safe_message(),
"conversation continuation must be rebuilt"
);
assert_eq!(
gateway.client_error_code(),
Some("previous_response_not_found")
);
}
#[test]
fn client_visible_upstream_error_should_preserve_opaque_structured_fields() {
let message = format!("\0{}\n", "m".repeat(9_000));
@@ -1,7 +1,7 @@
use bytes::Bytes;
use gateway_core::operation::{
Feature, GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind,
ProtocolPayload, ProviderSessionState, RawJsonPayload,
ProtocolPayload, ProviderSessionState, RawJsonPayload, StandaloneSearchRequest,
};
use serde_json::{Map, Value, json};
@@ -121,10 +121,43 @@ fn operation_kind_should_remain_stable() {
ImageRequestKind::Generation,
json!({"model": "gpt-image-2", "prompt": "draw"}),
));
let search = Operation::Search(StandaloneSearchRequest::from_raw_json(
RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{ "model":"gpt-test", "commands":{} }"#),
)
.expect("OpenAI search payload"),
));
assert_eq!(generate.kind(), OperationKind::Generate);
assert_eq!(image.kind(), OperationKind::GenerateImage);
assert_eq!(search.kind(), OperationKind::Search);
assert_eq!(OperationKind::Search.as_str(), "search");
assert!(image.image_generation_requested());
assert!(!search.image_generation_requested());
}
#[test]
fn standalone_search_should_keep_raw_body_and_context_opaque() {
let body = Bytes::from_static(
br#"{ "model":"gpt-future", "commands":{}, "future":9007199254740993, "future":2 }"#,
);
let secret = "private-turn-metadata";
let payload = RawJsonPayload::new("openai", body.clone())
.expect("OpenAI search payload")
.with_context(Map::from_iter([(
"turn_metadata".to_owned(),
Value::String(secret.to_owned()),
)]));
let request = StandaloneSearchRequest::from_raw_json(payload);
assert_eq!(request.payload().body(), &body);
assert_eq!(
request.payload().context().get("turn_metadata"),
Some(&Value::String(secret.to_owned()))
);
assert!(!format!("{request:?}").contains(secret));
assert!(!format!("{request:?}").contains("gpt-future"));
}
#[test]
@@ -11,7 +11,9 @@ use gateway_core::engine::{
ModelRequestFinalization as CoreModelRequestFinalization, ModelRequestId,
NewModelRequest as CoreNewModelRequest, ProbeFailure, RecoveryReport as CoreRecoveryReport,
};
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
use gateway_core::error::{
ProviderErrorKind, StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind,
};
use gateway_core::metering::{CostSource as CoreCostSource, Usage as CoreUsage};
use gateway_core::routing::{AccountRoutingScopeKind, AccountRoutingSnapshot};
use gateway_core::upstream::UpstreamSendState as CoreUpstreamSendState;
@@ -99,12 +101,20 @@ pub struct NewModelRequest {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: bool,
pub continuation: ContinuationRequestObservation,
pub image_generation_requested: bool,
pub admission_decision_ms: Option<u64>,
pub started_at: DateTime<Utc>,
pub deadline_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ContinuationRequestObservation {
pub affinity_hash: Option<String>,
pub previous_response_id_hash: Option<String>,
pub requested: bool,
}
impl NewModelRequest {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "id", &self.id)?;
@@ -133,6 +143,31 @@ impl NewModelRequest {
{
return Err(invalid("live client key ID must equal its historical ref"));
}
self.continuation.validate()?;
Ok(())
}
}
impl ContinuationRequestObservation {
fn validate(&self) -> StoreResult<()> {
for value in [
self.affinity_hash.as_deref(),
self.previous_response_id_hash.as_deref(),
]
.into_iter()
.flatten()
{
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(invalid("continuation observation hash is invalid"));
}
}
if !self.requested && self.previous_response_id_hash.is_some() {
return Err(invalid("continuation request facts do not agree"));
}
Ok(())
}
}
@@ -252,6 +287,11 @@ pub struct ModelRequestFinalization {
pub provider_error_code: Option<String>,
pub error_message: Option<String>,
pub raw_upstream_error: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub retry_after_ms: Option<u64>,
pub usage: ModelRequestUsage,
pub image_generation_succeeded: Option<bool>,
@@ -313,6 +353,11 @@ impl ModelRequestFinalization {
{
return Err(invalid("provider observation must be a JSON object"));
}
validate_optional_stable_reason(
self.continuation_unavailable_reason.as_deref(),
"continuation unavailable reason",
)?;
validate_connection_observation(self)?;
self.timings.validate()
}
}
@@ -389,11 +434,13 @@ impl ModelRequestRepository for PgExecutionStore {
operation, endpoint, client_transport, requested_model_id,
client_ip, user_agent, reasoning_effort,
reasoning_preset, request_kind, subagent_kind, compact,
image_generation_requested, admission_decision_ms, started_at, deadline_at
image_generation_requested, admission_decision_ms, started_at, deadline_at,
continuation_affinity_hash, continuation_previous_response_id_hash,
continuation_requested
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13::inet, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
)",
)
.bind(request.id)
@@ -422,6 +469,9 @@ impl ModelRequestRepository for PgExecutionStore {
)?)
.bind(request.started_at)
.bind(request.deadline_at)
.bind(request.continuation.affinity_hash)
.bind(request.continuation.previous_response_id_hash)
.bind(request.continuation.requested)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("insert model request"))?;
@@ -452,13 +502,15 @@ impl ModelRequestRepository for PgExecutionStore {
upstream_model_id, upstream_transport, http_version,
attempt_count, upstream_send_state, account_selection_wait_ms,
capacity_used_slots, capacity_total_slots
, continuation_affinity_hash, continuation_previous_response_id_hash,
continuation_requested
) select
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13::inet, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23,
$24, $25, $26,
account.name, account.email, account.authentication_kind,
$27, $28, $29, 1, 'not_sent', $30, $31, $32
$27, $28, $29, 1, 'not_sent', $30, $31, $32, $33, $34, $35
from (values (true)) as seed(present)
left join provider_accounts account on account.id = $25",
)
@@ -506,6 +558,9 @@ impl ModelRequestRepository for PgExecutionStore {
attempt.capacity_total_slots,
"capacity_total_slots",
)?)
.bind(request.continuation.affinity_hash)
.bind(request.continuation.previous_response_id_hash)
.bind(request.continuation.requested)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("insert model request with first attempt"))?;
@@ -644,8 +699,9 @@ impl ModelRequestRepository for PgExecutionStore {
finalization: ModelRequestFinalization,
) -> StoreResult<bool> {
finalization.validate()?;
let result = sqlx::query(
"update model_requests
let finalized = sqlx::query_scalar::<_, i64>(
"with finalized as (
update model_requests
set outcome = $2, upstream_send_state = $3, attempt_count = $4,
downstream_committed_at = $5,
client_status_code = coalesce(client_status_code, $6),
@@ -664,8 +720,103 @@ impl ModelRequestRepository for PgExecutionStore {
upstream_transport = coalesce($37, upstream_transport),
http_version = coalesce($38, http_version), websocket_pool = $39,
service_tier = $40, provider_observation_json = $41,
raw_upstream_error = $42
where id = $1 and outcome = 'running'",
raw_upstream_error = $42,
continuation_unavailable_reason = $43,
upstream_connection_id = $44,
upstream_connection_exit_reason = $45,
upstream_connection_age_ms = $46,
upstream_connection_idle_ms = $47
where id = $1 and outcome = 'running'
returning id, client_api_key_ref, continuation_affinity_hash,
continuation_requested, provider_kind, upstream_transport,
outcome, started_at, completed_at
), recovery_target as (
select prior.id
from model_requests prior
cross join finalized current
where not current.continuation_requested
and current.continuation_affinity_hash is not null
and prior.client_api_key_ref = current.client_api_key_ref
and prior.continuation_affinity_hash = current.continuation_affinity_hash
and prior.continuation_requested
and prior.outcome = 'failed'
and prior.error_kind = 'continuation_recovery_required'
and prior.recovered_at is null
and current.started_at >= prior.completed_at
and current.started_at <= prior.completed_at + interval '30 seconds'
order by prior.completed_at desc, prior.id desc
limit 1
), recovery_update as (
update model_requests prior
set recovery_attempt_count = prior.recovery_attempt_count + 1,
recovery_request_id = case
when current.outcome = 'succeeded' then current.id
else prior.recovery_request_id
end,
recovered_at = case
when current.outcome = 'succeeded' then current.completed_at
else prior.recovered_at
end,
recovery_retry_delay_ms = case
when current.outcome = 'succeeded' then greatest(
0,
floor(extract(epoch from (current.started_at - prior.completed_at)) * 1000)
)::bigint
else prior.recovery_retry_delay_ms
end,
recovery_total_latency_ms = case
when current.outcome = 'succeeded' then greatest(
0,
floor(extract(epoch from (current.completed_at - prior.completed_at)) * 1000)
)::bigint
else prior.recovery_total_latency_ms
end
from finalized current
join recovery_target target on true
where prior.id = target.id
and prior.recovered_at is null
returning prior.id
), session_transport_recovery_target as (
select prior.id
from model_requests prior
cross join finalized current
where current.outcome = 'succeeded'
and current.provider_kind = 'openai'
and current.upstream_transport = 'http_sse'
and current.continuation_affinity_hash is not null
and prior.client_api_key_ref = current.client_api_key_ref
and prior.continuation_affinity_hash = current.continuation_affinity_hash
and prior.provider_kind = current.provider_kind
and prior.outcome = 'failed'
and prior.error_kind = 'upstream_unavailable'
and prior.upstream_transport = 'websocket'
and prior.upstream_send_state = 'ambiguous'
and prior.downstream_committed_at is null
and prior.recovered_at is null
and current.started_at >= prior.completed_at
and current.started_at <= prior.completed_at + interval '30 seconds'
), session_transport_recovery_update as (
update model_requests prior
set recovery_attempt_count = prior.recovery_attempt_count + 1,
recovery_request_id = current.id,
recovered_at = current.completed_at,
recovery_retry_delay_ms = greatest(
0,
floor(extract(epoch from (current.started_at - prior.completed_at)) * 1000)
)::bigint,
recovery_total_latency_ms = greatest(
0,
floor(extract(epoch from (current.completed_at - prior.completed_at)) * 1000)
)::bigint
from finalized current
join session_transport_recovery_target target on true
where prior.id = target.id
and prior.recovered_at is null
returning prior.id
)
select (select count(*) from finalized)::bigint
+ (select count(*) * 0 from recovery_update)::bigint
+ (select count(*) * 0 from session_transport_recovery_update)::bigint",
)
.bind(&finalization.model_request_id)
.bind(finalization.outcome.as_str())
@@ -754,10 +905,21 @@ impl ModelRequestRepository for PgExecutionStore {
.bind(finalization.service_tier)
.bind(finalization.provider_metadata_json.map(sqlx::types::Json))
.bind(finalization.raw_upstream_error)
.execute(&self.pool)
.bind(finalization.continuation_unavailable_reason)
.bind(finalization.upstream_connection_id)
.bind(finalization.upstream_connection_exit_reason)
.bind(optional_i64(
finalization.upstream_connection_age_ms,
"upstream_connection_age_ms",
)?)
.bind(optional_i64(
finalization.upstream_connection_idle_ms,
"upstream_connection_idle_ms",
)?)
.fetch_one(&self.pool)
.await
.map_err(|_| postgres_unavailable("finalize model request"))?;
Ok(result.rows_affected() == 1)
Ok(finalized == 1)
}
async fn recover_expired_model_requests(
@@ -973,6 +1135,22 @@ impl ExecutionStore for PgExecutionStore {
&self,
finalization: CoreModelRequestFinalization,
) -> Result<(), CoreStoreError> {
let continuation_unavailable_reason = finalization
.failure_observation
.continuation_unavailable_reason
.clone();
let connection_observation = finalization
.failure_observation
.upstream_connection
.as_ref();
let upstream_connection_id =
connection_observation.map(|observation| observation.connection_id().to_owned());
let upstream_connection_exit_reason =
connection_observation.map(|observation| observation.exit_reason().to_owned());
let upstream_connection_age_ms =
connection_observation.map(|observation| observation.age_ms());
let upstream_connection_idle_ms =
connection_observation.map(|observation| observation.idle_ms());
let provider_metadata_json = finalization
.provider_metadata_json
.as_deref()
@@ -999,10 +1177,18 @@ impl ExecutionStore for PgExecutionStore {
),
None => (CostSource::Unavailable, None, None),
};
let error_kind = finalization
.error
.as_ref()
.map(|error| error.kind().as_str().to_owned());
let error_kind = if continuation_unavailable_reason.is_some() {
Some(
ProviderErrorKind::ContinuationRecoveryRequired
.as_str()
.to_owned(),
)
} else {
finalization
.error
.as_ref()
.map(|error| error.kind().as_str().to_owned())
};
let error_message = finalization.error.as_ref().map(|error| {
error.diagnostic().map_or_else(
|| error.safe_message().to_owned(),
@@ -1033,6 +1219,11 @@ impl ExecutionStore for PgExecutionStore {
provider_error_code: finalization.provider_error_code,
error_message,
raw_upstream_error: finalization.raw_upstream_error,
continuation_unavailable_reason,
upstream_connection_id,
upstream_connection_exit_reason,
upstream_connection_age_ms,
upstream_connection_idle_ms,
retry_after_ms: finalization.retry_after_ms,
usage: usage_from_core(finalization.usage),
image_generation_succeeded: finalization.image_generation_succeeded,
@@ -1162,6 +1353,11 @@ fn new_model_request_row(request: CoreNewModelRequest) -> NewModelRequest {
request_kind: request.request_kind,
subagent_kind: request.subagent_kind,
compact: request.compact,
continuation: ContinuationRequestObservation {
affinity_hash: request.continuation.affinity_hash,
previous_response_id_hash: request.continuation.previous_response_id_hash,
requested: request.continuation.requested,
},
image_generation_requested: request.image_generation_requested,
admission_decision_ms: request.admission_decision_ms,
started_at: DateTime::<Utc>::from(request.started_at),
@@ -1246,6 +1442,52 @@ fn validate_status_code(status: Option<u16>) -> StoreResult<()> {
Ok(())
}
fn validate_optional_stable_reason(value: Option<&str>, field: &str) -> StoreResult<()> {
if value.is_some_and(|value| {
value.is_empty()
|| value.len() > 64
|| !value.bytes().enumerate().all(|(index, byte)| {
byte.is_ascii_lowercase() || (index > 0 && (byte.is_ascii_digit() || byte == b'_'))
})
}) {
return Err(invalid(field));
}
Ok(())
}
fn validate_connection_observation(finalization: &ModelRequestFinalization) -> StoreResult<()> {
let fields_present = [
finalization.upstream_connection_id.is_some(),
finalization.upstream_connection_exit_reason.is_some(),
finalization.upstream_connection_age_ms.is_some(),
finalization.upstream_connection_idle_ms.is_some(),
];
if fields_present.iter().any(|present| *present)
&& !fields_present.iter().all(|present| *present)
{
return Err(invalid("upstream connection observation is incomplete"));
}
if let Some(connection_id) = finalization.upstream_connection_id.as_deref()
&& (connection_id.is_empty()
|| connection_id.len() > 128
|| connection_id.chars().any(char::is_control))
{
return Err(invalid("upstream connection ID is invalid"));
}
validate_optional_stable_reason(
finalization.upstream_connection_exit_reason.as_deref(),
"upstream connection exit reason is invalid",
)?;
if finalization
.upstream_connection_age_ms
.zip(finalization.upstream_connection_idle_ms)
.is_some_and(|(age, idle)| idle > age)
{
return Err(invalid("upstream connection idle time exceeds its age"));
}
Ok(())
}
fn to_i64(value: u64, field: &'static str) -> StoreResult<i64> {
i64::try_from(value).map_err(|_| invalid(field))
}
@@ -39,7 +39,10 @@ pub use provider_accounts::*;
pub use retention::*;
pub use runtime_settings::*;
pub use snapshot::*;
pub(crate) use usage_facts::{completed_usage_fact_predicate, push_completed_usage_fact_filter};
pub(crate) use usage_facts::{
completed_usage_fact_predicate, push_completed_usage_fact_filter,
push_unrecovered_request_filter,
};
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
@@ -51,13 +51,11 @@ pub(crate) fn store_ops_error_filter(
provider_account_ref: filter.provider_account_ref,
provider_kind: filter.provider_kind,
operation: filter.operation,
endpoint: filter.endpoint,
model: filter.model,
transport: filter.transport,
attempt_index: filter.attempt_index,
response_id: filter.response_id,
upstream_request_id: filter.upstream_request_id,
failure_kind: filter.failure_kind,
status_code: filter.status_code,
search: filter.search,
}
@@ -731,6 +729,18 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops
request_kind: error.request_kind,
subagent_kind: error.subagent_kind,
compact: error.compact,
continuation_affinity_hash: error.continuation_affinity_hash,
continuation_previous_response_id_hash: error.continuation_previous_response_id_hash,
continuation_unavailable_reason: error.continuation_unavailable_reason,
upstream_connection_id: error.upstream_connection_id,
upstream_connection_exit_reason: error.upstream_connection_exit_reason,
upstream_connection_age_ms: error.upstream_connection_age_ms,
upstream_connection_idle_ms: error.upstream_connection_idle_ms,
recovery_request_id: error.recovery_request_id,
recovered_at: error.recovered_at,
recovery_attempt_count: error.recovery_attempt_count,
recovery_retry_delay_ms: error.recovery_retry_delay_ms,
recovery_total_latency_ms: error.recovery_total_latency_ms,
occurred_at: error.occurred_at,
stable_sort_id: error.stable_sort_id,
}
@@ -910,6 +920,18 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
request_kind: get(row, "request_kind")?,
subagent_kind: get(row, "subagent_kind")?,
compact: get(row, "compact")?,
continuation_affinity_hash: get(row, "continuation_affinity_hash")?,
continuation_previous_response_id_hash: get(row, "continuation_previous_response_id_hash")?,
continuation_unavailable_reason: get(row, "continuation_unavailable_reason")?,
upstream_connection_id: get(row, "upstream_connection_id")?,
upstream_connection_exit_reason: get(row, "upstream_connection_exit_reason")?,
upstream_connection_age_ms: optional_unsigned(row, "upstream_connection_age_ms")?,
upstream_connection_idle_ms: optional_unsigned(row, "upstream_connection_idle_ms")?,
recovery_request_id: get(row, "recovery_request_id")?,
recovered_at: get(row, "recovered_at")?,
recovery_attempt_count: to_u32(get(row, "recovery_attempt_count")?)?,
recovery_retry_delay_ms: optional_unsigned(row, "recovery_retry_delay_ms")?,
recovery_total_latency_ms: optional_unsigned(row, "recovery_total_latency_ms")?,
occurred_at: get(row, "occurred_at")?,
stable_sort_id: get(row, "stable_sort_id")?,
})
@@ -18,7 +18,10 @@ use crate::{
require_nonempty,
};
use super::{completed_usage_fact_predicate, push_completed_usage_fact_filter};
use super::{
completed_usage_fact_predicate, push_completed_usage_fact_filter,
push_unrecovered_request_filter,
};
mod admin_adapter;
mod mapping;
@@ -126,13 +126,11 @@ pub struct OpsErrorFilter {
pub provider_account_ref: Option<String>,
pub provider_kind: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
@@ -148,14 +146,12 @@ impl OpsErrorFilter {
),
(self.provider_kind.as_deref(), "provider filter"),
(self.operation.as_deref(), "operation filter"),
(self.endpoint.as_deref(), "endpoint filter"),
(self.model.as_deref(), "model filter"),
(self.transport.as_deref(), "transport filter"),
(
self.upstream_request_id.as_deref(),
"upstream request ID filter",
),
(self.failure_kind.as_deref(), "failure kind filter"),
] {
validate_optional_text(value, MAX_FILTER_BYTES, field)?;
}
@@ -726,6 +722,18 @@ pub struct OpsErrorRecord {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
@@ -93,6 +93,7 @@ pub(crate) async fn request_metrics(
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
let row = query
.build()
@@ -113,7 +114,8 @@ pub(crate) async fn dashboard_totals(pool: &PgPool) -> StoreResult<DashboardTota
coalesce(sum(total_tokens) filter (where {fact}), 0)::bigint as total_tokens,
sum(cost_amount) filter (where {fact} and cost_currency = 'USD')::text
as billing_usd
from model_requests mr"
from model_requests mr
where mr.recovered_at is null"
));
let row = query
.build()
@@ -247,6 +249,7 @@ async fn request_metric_series_inner(
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(" group by bucket_start order by bucket_start");
let rows = query
@@ -373,6 +376,7 @@ pub(crate) async fn attempt_metrics(
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(format!(
"), request_aggregate as (
@@ -517,6 +521,7 @@ pub(crate) async fn provider_observations(
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
query.push(" group by coalesce(mr.provider_kind, 'unrouted') order by request_count desc");
let rows = query
@@ -24,6 +24,12 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
host(mr.client_ip) as client_ip, mr.user_agent,
mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact,
mr.continuation_affinity_hash, mr.continuation_previous_response_id_hash,
mr.continuation_unavailable_reason,
mr.upstream_connection_id, mr.upstream_connection_exit_reason,
mr.upstream_connection_age_ms, mr.upstream_connection_idle_ms,
mr.recovery_request_id, mr.recovered_at, mr.recovery_attempt_count,
mr.recovery_retry_delay_ms, mr.recovery_total_latency_ms,
mr.completed_at as occurred_at,
'model_request:' || mr.id as stable_sort_id
from model_requests mr
@@ -46,6 +52,15 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
host(mr.client_ip) as client_ip, mr.user_agent,
mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact,
mr.continuation_affinity_hash, mr.continuation_previous_response_id_hash,
null::text as continuation_unavailable_reason,
null::text as upstream_connection_id,
null::text as upstream_connection_exit_reason,
null::bigint as upstream_connection_age_ms,
null::bigint as upstream_connection_idle_ms,
mr.recovery_request_id, mr.recovered_at,
coalesce(mr.recovery_attempt_count, 0) as recovery_attempt_count,
mr.recovery_retry_delay_ms, mr.recovery_total_latency_ms,
oe.created_at as occurred_at,
'ops_event:' || oe.id as stable_sort_id
from ops_events oe
@@ -120,16 +135,11 @@ fn push_request_error_predicates(
("mr.provider_account_ref", &filter.provider_account_ref),
("mr.provider_kind", &filter.provider_kind),
("mr.operation", &filter.operation),
("mr.endpoint", &filter.endpoint),
("mr.upstream_transport", &filter.transport),
("mr.upstream_request_id", &filter.upstream_request_id),
] {
push_text_equality(statement, column, value);
}
if let Some(value) = &filter.failure_kind {
statement.push(" and coalesce(mr.error_kind, 'failed') = ");
statement.push_bind(value.clone());
}
push_response_id_filter(statement, "mr.client_response_id", filter);
push_text_equality(statement, "mr.upstream_model_id", &filter.model);
if let Some(index) = filter.attempt_index {
@@ -167,9 +177,7 @@ fn push_ops_event_predicates(
("oe.provider_account_ref", &filter.provider_account_ref),
("oe.provider_kind", &filter.provider_kind),
("oe.operation", &filter.operation),
("mr.endpoint", &filter.endpoint),
("oe.upstream_request_id", &filter.upstream_request_id),
("oe.failure_kind", &filter.failure_kind),
] {
push_text_equality(statement, column, value);
}
@@ -88,7 +88,19 @@ pub(crate) fn push_usage_filter(
query.push(format!(" escape '\\' or {alias}.{column} like "));
query.push_bind(pattern.clone());
}
query.push(" escape '\\')");
if value.starts_with("sk_") {
query.push(format!(
" escape '\\' or exists (
select 1 from client_api_keys searched_client_key
where searched_client_key.id = {alias}.client_api_key_ref
and searched_client_key.key like "
));
query.push_bind(pattern);
query.push(" escape '\\')");
} else {
query.push(" escape '\\'");
}
query.push(")");
}
}
@@ -360,6 +372,7 @@ pub(crate) async fn usage_diagnostics(
statement.push_bind(range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
push_unrecovered_request_filter(&mut statement, "mr");
push_diagnostic_dimension_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
statement.push(
@@ -18,3 +18,10 @@ pub(crate) fn completed_usage_fact_predicate(alias: &str) -> String {
pub(crate) fn push_completed_usage_fact_filter(query: &mut QueryBuilder<Postgres>, alias: &str) {
query.push(format!(" and {}", completed_usage_fact_predicate(alias)));
}
/// 排除已由后续成功请求恢复的会话续接中间失败。
///
/// 原始请求审计仍保留该行;默认业务指标只把最终成功链视为一次结果。
pub(crate) fn push_unrecovered_request_filter(query: &mut QueryBuilder<Postgres>, alias: &str) {
query.push(format!(" and {alias}.recovered_at is null"));
}
@@ -1,9 +1,10 @@
use chrono::{Duration, Utc};
use gateway_core::engine::{
ExecutionOutcome, ExecutionStore, ModelRequestFinalization as CoreModelRequestFinalization,
ModelRequestId, ModelRequestTimings as CoreModelRequestTimings,
ExecutionOutcome, ExecutionStore, ModelRequestFailureObservation,
ModelRequestFinalization as CoreModelRequestFinalization, ModelRequestId,
ModelRequestTimings as CoreModelRequestTimings,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::error::{GatewayError, GatewayErrorKind, ProviderConnectionObservation};
use gateway_core::metering::{CalculatedCost, CostEstimate, Usage};
use gateway_core::upstream::UpstreamSendState;
use gateway_store::postgres::{
@@ -42,6 +43,7 @@ fn model_request_rejects_mismatched_client_key_live_id() {
request_kind: None,
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: false,
started_at,
deadline_at: started_at + Duration::seconds(30),
@@ -93,6 +95,7 @@ async fn merged_model_less_first_attempt_should_match_sequential_semantics() {
request_kind: None,
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: false,
started_at,
deadline_at: started_at + Duration::seconds(30),
@@ -210,6 +213,7 @@ async fn model_request_persists_group_routing_snapshot_without_live_group_foreig
request_kind: None,
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: false,
started_at,
deadline_at: started_at + Duration::seconds(30),
@@ -477,6 +481,7 @@ fn successful_core_finalization(id: &str) -> CoreModelRequestFinalization {
error: None,
provider_error_code: None,
raw_upstream_error: None,
failure_observation: Default::default(),
retry_after_ms: None,
usage: Usage::new(),
image_generation_succeeded: None,
@@ -600,6 +605,457 @@ async fn core_adapter_should_reject_non_object_provider_observation() {
database.close().await;
}
#[tokio::test]
async fn core_adapter_persists_continuation_connection_failure_observation() {
let Some(database) = TestDatabase::create("execution_continuation_observation").await else {
return;
};
seed_running_request(&database.pool, "req_continuation_observation")
.await
.expect("seed model request");
let store = PgExecutionStore::new(database.pool.clone());
let mut finalization = successful_core_finalization("req_continuation_observation");
finalization.outcome = ExecutionOutcome::Failed;
finalization.client_status_code = Some(400);
finalization.error = Some(GatewayError::new(
GatewayErrorKind::InvalidRequest,
"conversation continuation must be rebuilt",
));
finalization.failure_observation = ModelRequestFailureObservation {
continuation_unavailable_reason: Some("reused_connection_lost".to_owned()),
upstream_connection: Some(ProviderConnectionObservation::new(
"connection-observed",
"tcp_reset",
12_000,
4_000,
)),
};
ExecutionStore::finalize_model_request(&store, finalization)
.await
.expect("persist continuation failure observation");
type PersistedContinuationObservation = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<i64>,
);
let persisted: PersistedContinuationObservation = sqlx::query_as(
"select error_kind, continuation_unavailable_reason,
upstream_connection_id, upstream_connection_exit_reason,
upstream_connection_age_ms, upstream_connection_idle_ms
from model_requests where id = 'req_continuation_observation'",
)
.fetch_one(&database.pool)
.await
.expect("load continuation failure observation");
assert_eq!(
persisted.0.as_deref(),
Some("continuation_recovery_required")
);
assert_eq!(persisted.1.as_deref(), Some("reused_connection_lost"));
assert_eq!(persisted.2.as_deref(), Some("connection-observed"));
assert_eq!(persisted.3.as_deref(), Some("tcp_reset"));
assert_eq!(persisted.4, Some(12_000));
assert_eq!(persisted.5, Some(4_000));
database.close().await;
}
#[tokio::test]
async fn successful_new_chain_should_mark_recent_continuation_failure_recovered() {
let Some(database) = TestDatabase::create("execution_continuation_recovered").await else {
return;
};
let affinity_hash = "a".repeat(64);
let failure_completed_at = Utc::now() - Duration::seconds(8);
seed_continuation_failure(
&database.pool,
"req_continuation_failed",
&affinity_hash,
failure_completed_at,
)
.await;
seed_recovery_request(
&database.pool,
"req_continuation_recovery",
&affinity_hash,
failure_completed_at + Duration::seconds(4),
)
.await;
let store = PgExecutionStore::new(database.pool.clone());
ExecutionStore::finalize_model_request(
&store,
successful_core_finalization("req_continuation_recovery"),
)
.await
.expect("finalize successful recovery request");
type PersistedRecoveryObservation = (
Option<String>,
Option<chrono::DateTime<Utc>>,
i32,
Option<i64>,
Option<i64>,
);
let recovered: PersistedRecoveryObservation = sqlx::query_as(
"select recovery_request_id, recovered_at, recovery_attempt_count,
recovery_retry_delay_ms, recovery_total_latency_ms
from model_requests where id = 'req_continuation_failed'",
)
.fetch_one(&database.pool)
.await
.expect("load recovered continuation failure");
assert_eq!(recovered.0.as_deref(), Some("req_continuation_recovery"));
assert!(recovered.1.is_some());
assert_eq!(recovered.2, 1);
assert_eq!(recovered.3, Some(4_000));
assert!(recovered.4.is_some_and(|latency| latency >= 4_000));
database.close().await;
}
#[tokio::test]
async fn successful_http_downgrade_should_mark_pending_websocket_failures_recovered() {
let Some(database) = TestDatabase::create("execution_transport_downgrade_recovered").await
else {
return;
};
let affinity_hash = "d".repeat(64);
let first_failure_at = Utc::now() - Duration::seconds(8);
let second_failure_at = first_failure_at + Duration::seconds(1);
seed_websocket_transport_failure(
&database.pool,
"req_websocket_failed_first",
&affinity_hash,
first_failure_at,
)
.await;
seed_websocket_transport_failure(
&database.pool,
"req_websocket_failed_second",
&affinity_hash,
second_failure_at,
)
.await;
seed_websocket_transport_failure(
&database.pool,
"req_websocket_failed_after_delivery",
&affinity_hash,
second_failure_at,
)
.await;
sqlx::query(
"update model_requests
set downstream_committed_at = completed_at
where id = 'req_websocket_failed_after_delivery'",
)
.execute(&database.pool)
.await
.expect("mark control failure delivered downstream");
let recovery_started_at = first_failure_at + Duration::seconds(4);
seed_transport_recovery_request(
&database.pool,
"req_http_fallback_succeeded",
&affinity_hash,
recovery_started_at,
)
.await;
let store = PgExecutionStore::new(database.pool.clone());
let mut recovery = successful_core_finalization("req_http_fallback_succeeded");
recovery.upstream_transport = Some("http_sse".to_owned());
recovery.downstream_committed_at = Some(std::time::SystemTime::now());
ExecutionStore::finalize_model_request(&store, recovery)
.await
.expect("finalize successful HTTP fallback");
type PersistedRecovery = (
String,
Option<String>,
Option<chrono::DateTime<Utc>>,
i32,
Option<i64>,
Option<i64>,
);
let recovered: Vec<PersistedRecovery> = sqlx::query_as(
"select id, recovery_request_id, recovered_at, recovery_attempt_count,
recovery_retry_delay_ms, recovery_total_latency_ms
from model_requests
where id like 'req_websocket_failed_%'
order by id",
)
.fetch_all(&database.pool)
.await
.expect("load WebSocket recovery observations");
assert_eq!(recovered.len(), 3);
let delivered = &recovered[0];
assert_eq!(delivered.0, "req_websocket_failed_after_delivery");
assert_eq!((&delivered.1, delivered.2, delivered.3), (&None, None, 0));
for (row, expected_delay_ms) in recovered[1..].iter().zip([4_000, 3_000]) {
assert_eq!(row.1.as_deref(), Some("req_http_fallback_succeeded"));
assert!(row.2.is_some());
assert_eq!(row.3, 1);
assert_eq!(row.4, Some(expected_delay_ms));
assert!(row.5.is_some_and(|latency| latency >= expected_delay_ms));
}
database.close().await;
}
#[tokio::test]
async fn websocket_success_or_late_http_success_should_not_hide_transport_failure() {
let Some(database) = TestDatabase::create("execution_transport_downgrade_unresolved").await
else {
return;
};
let failure_at = Utc::now() - Duration::seconds(60);
let store = PgExecutionStore::new(database.pool.clone());
let websocket_affinity = "e".repeat(64);
seed_websocket_transport_failure(
&database.pool,
"req_websocket_control_failure",
&websocket_affinity,
failure_at,
)
.await;
seed_transport_recovery_request(
&database.pool,
"req_websocket_control_success",
&websocket_affinity,
failure_at + Duration::seconds(4),
)
.await;
ExecutionStore::finalize_model_request(
&store,
successful_core_finalization("req_websocket_control_success"),
)
.await
.expect("finalize successful WebSocket control request");
let late_affinity = "f".repeat(64);
seed_websocket_transport_failure(
&database.pool,
"req_websocket_late_failure",
&late_affinity,
failure_at,
)
.await;
seed_transport_recovery_request(
&database.pool,
"req_http_late_success",
&late_affinity,
failure_at + Duration::seconds(31),
)
.await;
let mut late_recovery = successful_core_finalization("req_http_late_success");
late_recovery.upstream_transport = Some("http_sse".to_owned());
ExecutionStore::finalize_model_request(&store, late_recovery)
.await
.expect("finalize late HTTP request");
type UnresolvedTransportFailure = (String, Option<String>, Option<chrono::DateTime<Utc>>, i32);
let unresolved: Vec<UnresolvedTransportFailure> = sqlx::query_as(
"select id, recovery_request_id, recovered_at, recovery_attempt_count
from model_requests
where id in ('req_websocket_control_failure', 'req_websocket_late_failure')
order by id",
)
.fetch_all(&database.pool)
.await
.expect("load unresolved transport failures");
assert_eq!(unresolved.len(), 2);
assert!(
unresolved
.iter()
.all(|row| row.1.is_none() && row.2.is_none() && row.3 == 0)
);
database.close().await;
}
#[tokio::test]
async fn failed_or_late_new_chain_should_leave_continuation_failure_unresolved() {
let Some(database) = TestDatabase::create("execution_continuation_unresolved").await else {
return;
};
let affinity_hash = "c".repeat(64);
let failure_completed_at = Utc::now() - Duration::seconds(60);
seed_continuation_failure(
&database.pool,
"req_continuation_unresolved",
&affinity_hash,
failure_completed_at,
)
.await;
seed_recovery_request(
&database.pool,
"req_continuation_retry_failed",
&affinity_hash,
failure_completed_at + Duration::seconds(3),
)
.await;
let store = PgExecutionStore::new(database.pool.clone());
let mut failed = successful_core_finalization("req_continuation_retry_failed");
failed.outcome = ExecutionOutcome::Failed;
failed.client_status_code = Some(502);
failed.upstream_status_code = Some(500);
failed.error = Some(GatewayError::new(
GatewayErrorKind::UpstreamUnavailable,
"recovery request failed",
));
ExecutionStore::finalize_model_request(&store, failed)
.await
.expect("finalize failed recovery request");
let after_failed: (Option<String>, Option<chrono::DateTime<Utc>>, i32) = sqlx::query_as(
"select recovery_request_id, recovered_at, recovery_attempt_count
from model_requests where id = 'req_continuation_unresolved'",
)
.fetch_one(&database.pool)
.await
.expect("load unresolved continuation failure");
assert_eq!(after_failed, (None, None, 1));
seed_recovery_request(
&database.pool,
"req_continuation_retry_late",
&affinity_hash,
failure_completed_at + Duration::seconds(31),
)
.await;
ExecutionStore::finalize_model_request(
&store,
successful_core_finalization("req_continuation_retry_late"),
)
.await
.expect("finalize late recovery request");
let after_late: (Option<String>, Option<chrono::DateTime<Utc>>, i32) = sqlx::query_as(
"select recovery_request_id, recovered_at, recovery_attempt_count
from model_requests where id = 'req_continuation_unresolved'",
)
.fetch_one(&database.pool)
.await
.expect("load late unresolved continuation failure");
assert_eq!(after_late, (None, None, 1));
database.close().await;
}
async fn seed_continuation_failure(
pool: &sqlx::PgPool,
id: &str,
affinity_hash: &str,
completed_at: chrono::DateTime<Utc>,
) {
seed_running_request(pool, id)
.await
.expect("seed continuation failure request");
sqlx::query(
"update model_requests
set client_api_key_ref = 'key_continuation', client_transport = 'websocket',
continuation_affinity_hash = $2,
continuation_previous_response_id_hash = $3,
continuation_requested = true,
outcome = 'failed', error_kind = 'continuation_recovery_required',
continuation_unavailable_reason = 'reused_connection_lost',
started_at = $4 - interval '1 second', deadline_at = $4,
completed_at = $4
where id = $1",
)
.bind(id)
.bind(affinity_hash)
.bind("b".repeat(64))
.bind(completed_at)
.execute(pool)
.await
.expect("finalize seeded continuation failure");
}
async fn seed_recovery_request(
pool: &sqlx::PgPool,
id: &str,
affinity_hash: &str,
started_at: chrono::DateTime<Utc>,
) {
seed_running_request(pool, id)
.await
.expect("seed continuation recovery request");
sqlx::query(
"update model_requests
set client_api_key_ref = 'key_continuation', client_transport = 'websocket',
continuation_affinity_hash = $2, continuation_requested = false,
started_at = $3, deadline_at = $3 + interval '1 minute'
where id = $1",
)
.bind(id)
.bind(affinity_hash)
.bind(started_at)
.execute(pool)
.await
.expect("prepare seeded recovery request");
}
async fn seed_websocket_transport_failure(
pool: &sqlx::PgPool,
id: &str,
affinity_hash: &str,
completed_at: chrono::DateTime<Utc>,
) {
seed_running_request(pool, id)
.await
.expect("seed WebSocket transport failure request");
sqlx::query(
"update model_requests
set client_api_key_ref = 'key_transport_recovery',
client_transport = 'http_sse',
continuation_affinity_hash = $2,
attempt_count = 1, upstream_send_state = 'ambiguous',
upstream_transport = 'websocket', outcome = 'failed',
error_kind = 'upstream_unavailable',
provider_error_code = 'websocket_close_1000',
started_at = $3 - interval '1 second', deadline_at = $3,
completed_at = $3
where id = $1",
)
.bind(id)
.bind(affinity_hash)
.bind(completed_at)
.execute(pool)
.await
.expect("finalize seeded WebSocket transport failure");
}
async fn seed_transport_recovery_request(
pool: &sqlx::PgPool,
id: &str,
affinity_hash: &str,
started_at: chrono::DateTime<Utc>,
) {
seed_running_request(pool, id)
.await
.expect("seed transport recovery request");
sqlx::query(
"update model_requests
set client_api_key_ref = 'key_transport_recovery',
client_transport = 'http_sse',
continuation_affinity_hash = $2,
started_at = $3, deadline_at = $3 + interval '1 minute'
where id = $1",
)
.bind(id)
.bind(affinity_hash)
.bind(started_at)
.execute(pool)
.await
.expect("prepare seeded transport recovery request");
}
async fn seed_running_request(pool: &sqlx::PgPool, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"insert into model_requests (
@@ -186,6 +186,48 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() {
database.close().await;
}
#[tokio::test]
async fn usage_search_should_match_client_api_key_prefix() {
let Some(database) = TestDatabase::create("usage_client_api_key_search").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
let plaintext_key = format!("sk_{}", "K".repeat(43));
sqlx::query(
"insert into client_api_keys (id, name, key, enabled, created_at, updated_at)
values ('key_observe', 'usage search', $1, true, $2, $2)",
)
.bind(&plaintext_key)
.bind(now)
.execute(&database.pool)
.await
.expect("seed searchable client API key");
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("observability range");
for search in [plaintext_key.as_str(), &plaintext_key[..10]] {
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("search usage by client API key");
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "req_observe_success");
}
database.close().await;
}
#[tokio::test]
async fn ops_search_should_treat_sql_wildcards_as_literals() {
let Some(database) = TestDatabase::create("ops_literal_wildcard_search").await else {
@@ -215,6 +257,90 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() {
database.close().await;
}
#[tokio::test]
async fn recovered_continuation_failure_should_be_visible_in_ops_but_hidden_from_business_metrics()
{
let Some(database) = TestDatabase::create("observability_recovered_continuation").await else {
return;
};
let now = Utc::now();
seed_observability_facts(&database.pool, now)
.await
.expect("seed observability facts");
sqlx::query(
"update model_requests
set error_kind = 'continuation_recovery_required',
continuation_affinity_hash = $2,
continuation_previous_response_id_hash = $3,
continuation_requested = true,
continuation_unavailable_reason = 'reused_connection_lost',
recovery_request_id = 'req_observe_success', recovered_at = $1,
recovery_attempt_count = 1, recovery_retry_delay_ms = 4000,
recovery_total_latency_ms = 8000
where id = 'req_observe_failed'",
)
.bind(now)
.bind("a".repeat(64))
.bind("b".repeat(64))
.execute(&database.pool)
.await
.expect("mark continuation failure recovered");
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("observability range");
let repository = observability_repository(&database.pool);
let overview = repository
.usage_summary(range, UsageRecordFilter::default())
.await
.expect("usage summary without recovered intermediates");
assert_eq!(overview.requests.request_count, 2);
assert_eq!(overview.requests.failure_count, 0);
assert_eq!(overview.attempts.attempt_count, 2);
assert_eq!(overview.attempts.failure_count, 0);
assert_eq!(overview.providers[0].request_count, 2);
assert_eq!(overview.providers[0].failure_count, 0);
let diagnostics = repository
.usage_diagnostics(
range,
UsageRecordFilter::default(),
DiagnosticDimension::Account,
)
.await
.expect("diagnostics without recovered intermediates");
assert_eq!(diagnostics[0].request_count, 2);
assert_eq!(diagnostics[0].failure_count, 0);
let errors = repository
.list_ops_errors(OpsErrorQuery {
range,
filter: OpsErrorFilter::default(),
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("ops errors retain recovered failures");
assert_eq!(errors.total, 2);
assert!(errors.items.iter().all(|error| {
error.recovery_request_id.as_deref() == Some("req_observe_success")
&& error.recovered_at.is_some()
&& error.recovery_attempt_count == 1
}));
let retained: (String, Option<String>) = sqlx::query_as(
"select outcome, recovery_request_id
from model_requests where id = 'req_observe_failed'",
)
.fetch_one(&database.pool)
.await
.expect("load retained recovery audit row");
assert_eq!(
retained,
("failed".to_owned(), Some("req_observe_success".to_owned()))
);
database.close().await;
}
#[tokio::test]
async fn dashboard_account_metrics_should_partition_account_statuses() {
let Some(database) = TestDatabase::create("dashboard_account_metrics").await else {
@@ -762,9 +888,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some("acct_observe".to_owned()),
operation: Some("responses".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
model: Some("upstream-model".to_owned()),
failure_kind: Some("rate_limited".to_owned()),
status_code: Some(429),
search: Some("req_observe_failed".to_owned()),
..admin_observability::OpsErrorFilter::default()
@@ -875,6 +875,7 @@ fn new_request(id: &str, started_at: DateTime<Utc>) -> NewModelRequest {
request_kind: None,
subagent_kind: None,
compact: false,
continuation: Default::default(),
image_generation_requested: false,
started_at,
deadline_at: started_at + chrono::Duration::seconds(30),
@@ -32,6 +32,12 @@ impl CodexSessionAffinity {
&self.key_hash
}
/// 返回可持久化的客户端作用域不透明会话关联值。
#[must_use]
pub(crate) fn persistence_hash(&self) -> &str {
self.key.expose_to_store()
}
#[must_use]
pub(crate) const fn anchor_source(&self) -> &'static str {
self.anchor_source
@@ -58,6 +64,20 @@ impl CodexSessionAffinity {
}
}
/// 将原始 response ID 投影为客户端作用域的不可逆关联值。
#[must_use]
pub(crate) fn derive_previous_response_id_hash(
previous_response_id: &str,
client_api_key_id: &ClientApiKeyId,
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"codex-previous-response-observation-v1\0");
hasher.update(client_api_key_id.as_str().as_bytes());
hasher.update(b"\0");
hasher.update(previous_response_id.as_bytes());
hex::encode(hasher.finalize())
}
pub(crate) fn derive_codex_session_affinity(
request: &CodexResponsesRequest,
client_api_key_id: &ClientApiKeyId,
@@ -16,8 +16,8 @@ pub mod token_client;
mod types;
pub(crate) use affinity::{
CODEX_ROOT_SESSION_TTL, CodexSessionAffinity, derive_codex_cyber_policy_session_key,
derive_codex_session_affinity,
CodexSessionAffinity, derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
derive_previous_response_id_hash,
};
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
+1 -1
View File
@@ -30,7 +30,7 @@ use crate::transport::{CodexWebSocketPool, build_reqwest_client};
pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError};
pub use provider::{
CodexProvider, CodexProviderConfigError, CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH,
OFFICIAL_CODEX_BASE_URL,
OFFICIAL_CODEX_BASE_URL, openai_failure_affects_account_score,
};
pub mod credential;
@@ -22,11 +22,66 @@ impl CodexProvider {
),
ImageRequestKind::Edit => (CODEX_IMAGE_EDITS_PATH, self.image_edits_url.clone()),
};
let image_turn_id = image
.payload()
.context()
.get("image_turn_id")
.and_then(Value::as_str)
.map(str::to_owned);
self.execute_raw_json_endpoint(
context,
RawJsonEndpointRequest {
response_origin,
endpoint_path,
body: image.payload().body().clone(),
image_turn_id,
turn_metadata: None,
},
)
.await
}
pub(super) async fn execute_search(
&self,
search: &StandaloneSearchRequest,
candidate: &ProviderCandidate,
context: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
if search.payload().protocol() != PROVIDER_NAME || candidate.upstream_model().is_some() {
return Err(provider_error(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
));
}
let turn_metadata = search
.payload()
.context()
.get("turn_metadata")
.and_then(Value::as_str)
.map(str::to_owned);
self.execute_raw_json_endpoint(
context,
RawJsonEndpointRequest {
response_origin: self.search_url.clone(),
endpoint_path: CODEX_ALPHA_SEARCH_PATH,
body: search.payload().body().clone(),
image_turn_id: None,
turn_metadata,
},
)
.await
}
async fn execute_raw_json_endpoint(
&self,
context: AttemptContext,
request: RawJsonEndpointRequest,
) -> Result<ProviderStream, ProviderError> {
let selection_started_at = Instant::now();
let lease = self
.selector
.select_for_provider_endpoint(&SelectCodexProviderEndpointCredential {
request_url: &response_origin,
request_url: &request.response_origin,
attempt: &context,
})
.await
@@ -48,18 +103,18 @@ impl CodexProvider {
account_selection_wait_ms,
lease.capacity_snapshot(),
));
let image_turn_id = image
.payload()
.context()
.get("image_turn_id")
.and_then(Value::as_str)
.map(str::to_owned);
// Standalone Provider 端点没有可证明的账号 owner;Search metadata 必须按
// 跨账号输入收敛到当前 lease,不能沿用下游声明的账号或 installation identity。
let turn_metadata = request.turn_metadata.as_deref().and_then(|metadata| {
crate::transport::request::scope_turn_metadata(metadata, lease.installation_id(), true)
});
let events = cold_json_response_stream(ColdJsonResponse {
client: self.client.clone(),
response_origin,
endpoint_path,
body: image.payload().body().clone(),
image_turn_id,
response_origin: request.response_origin,
endpoint_path: request.endpoint_path,
body: request.body,
image_turn_id: request.image_turn_id,
turn_metadata,
context,
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
@@ -68,19 +123,31 @@ impl CodexProvider {
});
let stream = ProviderStream::new(metadata, events, lease);
Ok(if allows_account_state_mutation {
stream.with_account_feedback(Arc::clone(&self.account_feedback))
stream.with_filtered_account_feedback(
Arc::clone(&self.account_feedback),
openai_failure_affects_account_score,
)
} else {
stream
})
}
}
struct RawJsonEndpointRequest {
response_origin: Url,
endpoint_path: &'static str,
body: Bytes,
image_turn_id: Option<String>,
turn_metadata: Option<String>,
}
pub(super) struct ColdResponse {
pub(super) client: CodexBackendClient,
pub(super) response_origin: Url,
pub(super) request: CodexResponsesRequest,
pub(super) upstream_model: UpstreamModelId,
pub(super) transport_policy: CodexProviderTransport,
pub(super) websocket_connection_preference: WebSocketConnectionPreference,
pub(super) context: AttemptContext,
pub(super) selector: Arc<CodexCredentialSelector>,
pub(super) quota: Arc<CodexCredentialQuotaService>,
@@ -89,7 +156,8 @@ pub(super) struct ColdResponse {
pub(super) output_started_at: Instant,
pub(super) session_affinity_key: Option<ProviderSessionAffinityKey>,
pub(super) session_affinity_key_hash: Option<String>,
pub(super) session_transport_fallbacks: CodexSessionTransportFallbacks,
pub(super) session_transport_recovery: CodexSessionTransportRecovery,
pub(super) session_recovery_probe: Option<CodexSessionWebSocketProbe>,
pub(super) websocket_retry_count: u32,
pub(super) stream_max_retries: u32,
pub(super) session_capture: Option<OpenAiSessionCapture>,
@@ -101,6 +169,7 @@ pub(super) struct ColdJsonResponse {
pub(super) endpoint_path: &'static str,
pub(super) body: Bytes,
pub(super) image_turn_id: Option<String>,
pub(super) turn_metadata: Option<String>,
pub(super) context: AttemptContext,
pub(super) selector: Arc<CodexCredentialSelector>,
pub(super) quota: Arc<CodexCredentialQuotaService>,
@@ -225,6 +294,7 @@ pub(super) async fn create_response_attempt(
account_id: &str,
deadline: SystemTime,
cancellation: &CancellationToken,
connection_preference: WebSocketConnectionPreference,
) -> Result<CodexBackendStreamingResponse, CodexHandshakeAttemptError> {
let Some(handshake_deadline) = remaining(deadline) else {
return Err(CodexHandshakeAttemptError::Timeout);
@@ -237,6 +307,7 @@ pub(super) async fn create_response_attempt(
request,
request_context,
Some(account_id),
connection_preference,
) => response.map_err(CodexHandshakeAttemptError::Client),
}
}
@@ -276,6 +347,7 @@ pub(super) async fn create_json_attempt(
Some(installation_id),
);
request_context.cookie_header = cookie_header.map(ExposeSecret::expose_secret);
request_context.turn_metadata = request.turn_metadata.as_deref();
request_context.account_selection = account_selection;
tokio::select! {
biased;
@@ -379,7 +451,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
tracing::warn!(
account_id = %active_account.id(),
error = %error,
"Failed to persist OpenAI image response cookies"
"Failed to persist OpenAI provider endpoint response cookies"
);
}
}
@@ -404,6 +476,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
request,
upstream_model,
transport_policy,
websocket_connection_preference,
context,
selector,
quota,
@@ -412,7 +485,8 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
output_started_at,
session_affinity_key,
session_affinity_key_hash,
session_transport_fallbacks,
session_transport_recovery,
mut session_recovery_probe,
websocket_retry_count,
stream_max_retries,
mut session_capture,
@@ -462,6 +536,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
active_account.id().as_str(),
context.deadline(),
&cancellation,
websocket_connection_preference,
)
.await;
let websocket_failure_policy = match &response {
@@ -495,7 +570,8 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
account_id: active_account.id().as_str(),
session_affinity_key: session_affinity_key.as_ref(),
session_affinity_key_hash: session_affinity_key_hash.as_deref(),
session_transport_fallbacks: &session_transport_fallbacks,
session_transport_recovery: &session_transport_recovery,
session_recovery_probe: &mut session_recovery_probe,
},
);
}
@@ -633,7 +709,10 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(mut failure) => {
if failure.websocket_transport_retryable
&& response_transport == CodexBackendTransport::WebSocket
&& !pre_commit_events.is_committed()
&& (matches!(
failure.error.send_state(),
UpstreamSendState::Sent | UpstreamSendState::Ambiguous
) || !pre_commit_events.is_committed())
{
apply_websocket_recovery_policy(
&mut failure,
@@ -647,19 +726,29 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
account_id: active_account.id().as_str(),
session_affinity_key: session_affinity_key.as_ref(),
session_affinity_key_hash: session_affinity_key_hash.as_deref(),
session_transport_fallbacks: &session_transport_fallbacks,
session_transport_recovery: &session_transport_recovery,
session_recovery_probe: &mut session_recovery_probe,
},
);
}
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
if !updates.is_empty() {
let rate_limits_changed = if updates.is_empty() {
false
} else {
passive_quota_observation.observe(&updates);
let update_headers = rate_limit_update_headers(&updates);
if observation_state.merge_rate_limit_headers(&update_headers)
&& let Some(observation) = observation_state.observation()
{
yield ProviderEvent::observation(observation);
}
observation_state.merge_rate_limit_headers(&update_headers)
};
let turn_state_changed = merge_turn_state_update(
turn_state_updates.as_ref(),
&mut session_capture,
&mut observation_state,
)
.await;
if (rate_limits_changed || turn_state_changed)
&& let Some(observation) = observation_state.observation()
{
yield ProviderEvent::observation(observation);
}
if allows_account_state_mutation {
synchronize_passive_quota(
@@ -683,16 +772,12 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
passive_quota_observation.observe(&updates);
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
};
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
&& let Some(turn_state) = updates.lock().await.take()
{
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
} else {
false
};
let turn_state_changed = merge_turn_state_update(
turn_state_updates.as_ref(),
&mut session_capture,
&mut observation_state,
)
.await;
let first_event_changed =
observation_state.observe_stream_chunk(&chunk, output_started_at);
let chunk_len = chunk.len();
@@ -740,6 +825,21 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
.any(|event| matches!(event, GatewayEvent::Completed(_)));
let terminal_changed = completed
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
if response_transport == CodexBackendTransport::WebSocket
&& completed
&& terminal_failure.is_none()
&& let Some(probe) = session_recovery_probe.take()
{
let session_recovery_cleared = probe.succeed();
tracing::info!(
request_id = %context.request_id(),
attempt_index = context.attempt_index().get(),
session_affinity_key_hash = session_affinity_key_hash.as_deref().unwrap_or(""),
session_transport_action = "websocket_recovered",
session_recovery_cleared,
"OpenAI session fresh WebSocket probe reached a terminal response"
);
}
if allows_account_state_mutation && (completed || terminal_failure.is_some()) {
synchronize_passive_quota(
&quota,
@@ -854,16 +954,12 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
apply_failure(&failure_context, &active_account, failure)
.await;
}
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
&& let Some(turn_state) = updates.lock().await.take()
{
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
} else {
false
};
let turn_state_changed = merge_turn_state_update(
turn_state_updates.as_ref(),
&mut session_capture,
&mut observation_state,
)
.await;
attach_openai_session_update(&mut events, &mut session_capture);
let completed = events
.iter()
@@ -871,6 +967,21 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
.any(|event| matches!(event, GatewayEvent::Completed(_)));
let terminal_changed = completed
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
if response_transport == CodexBackendTransport::WebSocket
&& completed
&& terminal_failure.is_none()
&& let Some(probe) = session_recovery_probe.take()
{
let session_recovery_cleared = probe.succeed();
tracing::info!(
request_id = %context.request_id(),
attempt_index = context.attempt_index().get(),
session_affinity_key_hash = session_affinity_key_hash.as_deref().unwrap_or(""),
session_transport_action = "websocket_recovered",
session_recovery_cleared,
"OpenAI session fresh WebSocket probe reached a terminal response"
);
}
if allows_account_state_mutation && completed && terminal_failure.is_none() {
// 同上:尾部 finish() 也可能产出 completed,亲和记录必须先于任何下游 yield。
selector
@@ -914,3 +1025,20 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
}
})
}
async fn merge_turn_state_update(
updates: Option<&CodexTurnStateUpdate>,
session_capture: &mut Option<OpenAiSessionCapture>,
observation_state: &mut OpenAiResponseObservationState,
) -> bool {
let Some(updates) = updates else {
return false;
};
let Some(turn_state) = updates.lock().await.take() else {
return false;
};
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
}
@@ -2,6 +2,40 @@
use super::*;
/// OpenAI 失败对 Smart 账号分数的结构化 reason 闭集。
///
/// 已归一的上游 code 优先;code 缺失时才读取结构化客户端错误的 code/type。
/// 新增错误、裸 HTTP 状态和内部错误 kind 默认都不会进入该闭集。
const OPENAI_ACCOUNT_SCORE_FAILURE_REASONS: &[&str] = &[
"server_is_overloaded",
"slow_down",
"rate_limit_exceeded",
"rate_limit_error",
"server_error",
"service_unavailable_error",
];
fn is_openai_account_score_failure_reason(value: &str) -> bool {
let value = value.trim();
OPENAI_ACCOUNT_SCORE_FAILURE_REASONS
.iter()
.any(|reason| value.eq_ignore_ascii_case(reason))
}
fn openai_account_score_failure_reason(error: &ProviderError) -> Option<&str> {
if let Some(code) = error.upstream_code() {
return Some(code.as_str());
}
let upstream = error.client_visible_upstream_error()?;
upstream.code().or_else(|| upstream.error_type())
}
/// 返回该 OpenAI 失败是否属于 Smart 账号计分闭集。
#[doc(hidden)]
pub fn openai_failure_affects_account_score(error: &ProviderError) -> bool {
openai_account_score_failure_reason(error).is_some_and(is_openai_account_score_failure_reason)
}
pub(super) struct MappedProviderFailure {
pub(super) error: ProviderError,
pub(super) websocket_transport_retryable: bool,
@@ -424,7 +458,8 @@ pub(super) struct WebSocketRecoveryContext<'a> {
pub(super) account_id: &'a str,
pub(super) session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
pub(super) session_affinity_key_hash: Option<&'a str>,
pub(super) session_transport_fallbacks: &'a CodexSessionTransportFallbacks,
pub(super) session_transport_recovery: &'a CodexSessionTransportRecovery,
pub(super) session_recovery_probe: &'a mut Option<CodexSessionWebSocketProbe>,
}
pub(super) fn websocket_client_failure_policy(
@@ -454,7 +489,10 @@ pub(super) fn websocket_client_failure_policy(
Some(WebSocketFailurePolicy::Budgeted)
}
CodexClientError::WebSocket(error)
if !matches!(error, CodexWebSocketExchangeError::InvalidRequest(_)) =>
if !matches!(
error.classified(),
CodexWebSocketExchangeError::InvalidRequest(_)
) =>
{
Some(WebSocketFailurePolicy::Budgeted)
}
@@ -472,10 +510,18 @@ pub(super) fn apply_websocket_recovery_policy(
UpstreamSendState::Sent | UpstreamSendState::Ambiguous
) {
// payload 已经发送或发送结果不确定时,代理不能用同一业务输入自动重放。
// 只为客户端下一次完整请求记住 HTTP 偏好,当前请求保持终态失败。
let sticky_state_transition = context
.session_affinity_key
.map(|key| context.session_transport_fallbacks.disable_websocket(key));
// 当前请求保持终态失败;恢复状态只裁决客户端主动发起的下一次请求。
let session_recovery_transition = match context.session_recovery_probe.take() {
Some(probe) => Some(probe.post_send_failed()),
None => context.session_affinity_key.map(|key| {
context
.session_transport_recovery
.record_post_send_failure(key)
}),
};
let session_http_cooldown_ms = session_recovery_transition
.and_then(CodexSessionRecoveryTransition::cooldown)
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX));
tracing::warn!(
request_id = context.request_id,
attempt_index = context.attempt_index,
@@ -490,8 +536,12 @@ pub(super) fn apply_websocket_recovery_policy(
continuation_recovery_action = "client_replay_required",
session_affinity_present = context.session_affinity_key.is_some(),
session_affinity_key_hash = context.session_affinity_key_hash.unwrap_or(""),
sticky_http_enabled = sticky_state_transition.is_some(),
sticky_state_transition = sticky_state_transition.unwrap_or(false),
session_transport_action = session_recovery_transition
.map_or("untracked", CodexSessionRecoveryTransition::action),
session_websocket_failure_count = session_recovery_transition
.map_or(0, CodexSessionRecoveryTransition::failure_count),
session_http_cooldown_ms = session_http_cooldown_ms.unwrap_or_default(),
session_http_cooldown_present = session_http_cooldown_ms.is_some(),
"OpenAI upstream WebSocket failed after payload send; proxy replay was suppressed"
);
return;
@@ -539,9 +589,21 @@ pub(super) fn apply_websocket_recovery_policy(
}
failure.error.set_pre_delivery_transport_fallback();
let sticky_state_transition = context
.session_affinity_key
.map(|key| context.session_transport_fallbacks.disable_websocket(key));
let session_fallback = match context.policy {
WebSocketFailurePolicy::Budgeted => SessionWebSocketFallback::RetryBudgetExhausted,
WebSocketFailurePolicy::ImmediateFallback => SessionWebSocketFallback::UpgradeRequired,
};
let session_recovery_transition = match context.session_recovery_probe.take() {
Some(probe) => Some(probe.fallback(session_fallback)),
None => context.session_affinity_key.map(|key| {
context
.session_transport_recovery
.record_websocket_fallback(key, session_fallback)
}),
};
let session_http_cooldown_ms = session_recovery_transition
.and_then(CodexSessionRecoveryTransition::cooldown)
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX));
tracing::warn!(
request_id = context.request_id,
attempt_index = context.attempt_index,
@@ -561,9 +623,13 @@ pub(super) fn apply_websocket_recovery_policy(
},
session_affinity_present = context.session_affinity_key.is_some(),
session_affinity_key_hash = context.session_affinity_key_hash.unwrap_or(""),
sticky_http_enabled = sticky_state_transition.is_some(),
sticky_state_transition = sticky_state_transition.unwrap_or(false),
"OpenAI upstream WebSocket disabled for the current session"
session_transport_action =
session_recovery_transition.map_or("untracked", CodexSessionRecoveryTransition::action),
session_websocket_failure_count =
session_recovery_transition.map_or(0, CodexSessionRecoveryTransition::failure_count),
session_http_cooldown_ms = session_http_cooldown_ms.unwrap_or_default(),
session_http_cooldown_present = session_http_cooldown_ms.is_some(),
"OpenAI upstream WebSocket entered session HTTP cooldown"
);
}
@@ -581,15 +647,16 @@ pub(super) fn websocket_retry_backoff(retry_index: NonZeroU32) -> Duration {
Duration::from_millis(base_ms.saturating_mul(jitter_per_mille) / 1_000)
}
pub(super) fn continuation_replay_required_error() -> ProviderError {
pub(super) fn continuation_replay_required_error(reason: &'static str) -> ProviderError {
ProviderError::new(
ProviderErrorKind::InvalidRequest,
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_continuation_recovery_disposition(
ContinuationRecoveryDisposition::ClientReplayRequired,
)
.with_continuation_unavailable_reason(reason)
.with_upstream_code(OpaqueUpstreamValue::new(
PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned(),
))
@@ -620,7 +687,7 @@ pub(super) fn stream_transport_allows_pre_delivery_retry(error: &CodexClientErro
| CodexClientError::HttpJson(_)
| CodexClientError::StreamIdleTimeout { .. } => true,
CodexClientError::WebSocket(error) => !matches!(
error,
error.classified(),
CodexWebSocketExchangeError::InvalidRequest(_)
| CodexWebSocketExchangeError::Upstream(_)
| CodexWebSocketExchangeError::ContinuationUnavailable { .. }
@@ -666,12 +733,32 @@ pub(super) fn map_client_error(
_ => None,
};
let continuation_failure = match &error {
CodexClientError::WebSocket(CodexWebSocketExchangeError::ContinuationUnavailable {
reason: PreviousResponseUnavailableReason::ConnectionBusy,
}) => Some(ContinuationFailure::Busy),
CodexClientError::WebSocket(CodexWebSocketExchangeError::ContinuationUnavailable {
..
}) => Some(ContinuationFailure::HistoryUnavailable),
CodexClientError::WebSocket(error)
if error.continuation_unavailable_reason()
== Some(PreviousResponseUnavailableReason::ConnectionBusy) =>
{
Some(ContinuationFailure::Busy)
}
CodexClientError::WebSocket(error) if error.continuation_unavailable_reason().is_some() => {
Some(ContinuationFailure::HistoryUnavailable)
}
_ => None,
};
let continuation_unavailable_reason = match &error {
CodexClientError::WebSocket(error) => error
.continuation_unavailable_reason()
.map(PreviousResponseUnavailableReason::as_str),
_ => None,
};
let connection_observation = match &error {
CodexClientError::WebSocket(error) => error.connection_observation().map(|observation| {
ProviderConnectionObservation::new(
observation.connection_id().to_string(),
observation.exit_reason().to_owned(),
observation.age_ms(),
observation.idle_ms(),
)
}),
_ => None,
};
let observation = observe_transport
@@ -718,10 +805,10 @@ pub(super) fn map_client_error(
send_state,
))
}
CodexClientError::WebSocket(
error @ CodexWebSocketExchangeError::ContinuationUnavailable { .. },
) => {
let mut failure = MappedProviderFailure::plain(continuation_replay_required_error());
CodexClientError::WebSocket(error) if error.continuation_unavailable_reason().is_some() => {
let mut failure = MappedProviderFailure::plain(continuation_replay_required_error(
continuation_unavailable_reason.unwrap_or("scope_unavailable"),
));
if let Some(client_visible_error) = websocket_client_visible_error(&error) {
failure.error = failure
.error
@@ -744,7 +831,10 @@ pub(super) fn map_client_error(
"websocket_close_{close_code}"
)));
}
if matches!(error, CodexWebSocketExchangeError::ConnectionLimitReached) {
if matches!(
error.classified(),
CodexWebSocketExchangeError::ConnectionLimitReached
) {
failure.error = failure.error.with_upstream_code(OpaqueUpstreamValue::new(
WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE.to_owned(),
));
@@ -771,6 +861,11 @@ pub(super) fn map_client_error(
if let Some(raw) = raw_upstream_error {
failure.error = failure.error.with_raw_upstream_error(raw);
}
if let Some(connection_observation) = connection_observation {
failure.error = failure
.error
.with_connection_observation(connection_observation);
}
failure.observation = observation;
failure
}
@@ -790,7 +885,7 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
return ProviderDiagnostic::new(message);
}
ProviderDiagnostic::new(match error {
ProviderDiagnostic::new(match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_) => {
"OpenAI WebSocket request could not be constructed".to_owned()
}
@@ -804,9 +899,6 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
CodexWebSocketExchangeError::ConnectTimeout { timeout } => {
format!("OpenAI WebSocket connection timed out after {timeout:?}")
}
CodexWebSocketExchangeError::FastPathTimeout { timeout } => {
format!("OpenAI WebSocket fast-path budget expired after {timeout:?}")
}
CodexWebSocketExchangeError::OriginCircuitOpen => {
"OpenAI WebSocket origin circuit is open".to_owned()
}
@@ -847,6 +939,9 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
CodexWebSocketExchangeError::ClosedBeforeTerminal(_) => {
unreachable!("close-before-terminal errors are handled before the variant match")
}
CodexWebSocketExchangeError::ConnectionObserved { .. } => {
unreachable!("classified websocket errors never retain observation wrappers")
}
})
}
@@ -866,7 +961,10 @@ fn websocket_raw_error(error: &CodexWebSocketExchangeError) -> Option<RawUpstrea
pub(super) fn websocket_client_visible_error(
error: &CodexWebSocketExchangeError,
) -> Option<ClientVisibleUpstreamError> {
if matches!(error, CodexWebSocketExchangeError::ConnectionLimitReached) {
if matches!(
error.classified(),
CodexWebSocketExchangeError::ConnectionLimitReached
) {
return Some(ClientVisibleUpstreamError::new(
"websocket connection limit reached",
Some(WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE.to_owned()),
@@ -900,7 +998,12 @@ pub(super) fn map_upstream_failure(
.filter(|code| is_history_failure_code(code))
.map(|_| ContinuationFailure::HistoryUnavailable);
let send_state = upstream_send_state(failure.send_phase);
let mut error = provider_error(provider_error_kind(category), send_state);
let error_kind = if continuation_failure.is_some() {
ProviderErrorKind::ContinuationRecoveryRequired
} else {
provider_error_kind(category)
};
let mut error = provider_error(error_kind, send_state);
error = error.with_raw_upstream_error(RawUpstreamError::new(failure.raw_body.clone()));
if let Some(message) = failure.client_message.as_ref() {
error = error.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
@@ -939,7 +1042,8 @@ pub(super) fn map_upstream_failure(
.with_continuation_failure(continuation_failure)
.with_continuation_recovery_disposition(
ContinuationRecoveryDisposition::ClientReplayRequired,
);
)
.with_continuation_unavailable_reason("upstream_rejected");
}
if let Some(retry_after) = failure.retry_after_seconds.map(Duration::from_secs) {
error = error.with_retry_after(retry_after);
@@ -1064,12 +1168,11 @@ pub(super) fn account_failure(
}
}
pub(super) const fn websocket_send_state(error: &CodexWebSocketExchangeError) -> UpstreamSendState {
match error {
pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> UpstreamSendState {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_)
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::ConnectTimeout { .. }
| CodexWebSocketExchangeError::FastPathTimeout { .. }
| CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy
| CodexWebSocketExchangeError::SharedConnectFailed
@@ -1085,16 +1188,18 @@ pub(super) const fn websocket_send_state(error: &CodexWebSocketExchangeError) ->
| CodexWebSocketExchangeError::ReceiveIdleTimeout { .. }
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. }
| CodexWebSocketExchangeError::InitialEventTimeout { .. } => UpstreamSendState::Ambiguous,
CodexWebSocketExchangeError::ConnectionObserved { .. } => {
unreachable!("classified websocket errors never retain observation wrappers")
}
}
}
pub(super) const fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> ProviderErrorKind {
match error {
pub(super) fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> ProviderErrorKind {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_)
| CodexWebSocketExchangeError::InvalidSse(_)
| CodexWebSocketExchangeError::UnexpectedBinaryEvent => ProviderErrorKind::Protocol,
CodexWebSocketExchangeError::ConnectTimeout { .. }
| CodexWebSocketExchangeError::FastPathTimeout { .. }
| CodexWebSocketExchangeError::SendTimeout { .. }
| CodexWebSocketExchangeError::ReceiveIdleTimeout { .. }
| CodexWebSocketExchangeError::InitialEventTimeout { .. } => ProviderErrorKind::Timeout,
@@ -1113,6 +1218,9 @@ pub(super) const fn websocket_error_kind(error: &CodexWebSocketExchangeError) ->
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. } => {
ProviderErrorKind::Transport
}
CodexWebSocketExchangeError::ConnectionObserved { .. } => {
unreachable!("classified websocket errors never retain observation wrappers")
}
}
}
@@ -12,15 +12,15 @@ use futures::{StreamExt, future::BoxFuture};
use gateway_core::account::{AccountFeedbackStats, ProviderAccount};
use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuationScope};
use gateway_core::engine::provider::{
EventStream, Provider, ProviderCallMetadata, ProviderCatalogGeneration,
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation,
ProviderSelectionObservation, ProviderStream,
ContinuationRequestObservation, EventStream, Provider, ProviderCallMetadata,
ProviderCatalogGeneration, ProviderModelCapabilities, ProviderRequest,
ProviderRequestObservation, ProviderSelectionObservation, ProviderStream,
};
use gateway_core::engine::{AttemptContext, AttemptTransport, ContinuationAttempt};
use gateway_core::error::{
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ContinuationFailure,
ContinuationRecoveryDisposition, OpaqueUpstreamValue, ProviderDiagnostic, ProviderError,
ProviderErrorKind, RawUpstreamError,
ContinuationRecoveryDisposition, OpaqueUpstreamValue, ProviderConnectionObservation,
ProviderDiagnostic, ProviderError, ProviderErrorKind, RawUpstreamError,
};
use gateway_core::event::{
FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader,
@@ -29,7 +29,8 @@ use gateway_core::event::{
};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::operation::{
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProviderSessionState,
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind,
ProviderSessionState, StandaloneSearchRequest,
};
use gateway_core::provider_ports::ProviderSessionAffinityKey;
use gateway_core::routing::{
@@ -58,8 +59,12 @@ use crate::credential::{
CodexQuotaRefreshPolicy, CodexSessionAffinity, CredentialSelectionError, RuntimeCodexCookie,
SelectCodexCredential, SelectCodexProviderEndpointCredential,
derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
derive_previous_response_id_hash,
};
use crate::session_transport::{
CodexSessionRecoveryTransition, CodexSessionTransportDecision, CodexSessionTransportRecovery,
CodexSessionWebSocketProbe, SessionWebSocketFallback,
};
use crate::session_transport::CodexSessionTransportFallbacks;
use crate::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
};
@@ -84,11 +89,12 @@ use crate::transport::session::CodexSessionIdentity;
use crate::transport::usage::normalize_service_tier;
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
use crate::transport::{
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendJsonResponse,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates,
CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
CODEX_ALPHA_SEARCH_PATH, CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH,
CODEX_RESPONSES_PATH, CodexAccountSelectionTelemetry, CodexBackendClient,
CodexBackendJsonResponse, CodexBackendStreamingResponse, CodexBackendTransport,
CodexClientError, CodexRateLimitUpdates, CodexRequestContext, CodexResponseMetadata,
CodexTransportMetrics, CodexTurnStateUpdate, CodexUpstreamDiagnostics, CodexWebSocketPool,
WebSocketConnectionPreference, endpoint_url,
};
mod execution;
@@ -97,6 +103,8 @@ mod observation;
mod workers;
use execution::*;
#[doc(hidden)]
pub use failure::openai_failure_affects_account_score;
use failure::*;
use observation::*;
pub(crate) use workers::worker_contributions;
@@ -139,8 +147,9 @@ pub struct CodexProvider {
responses_url: Url,
image_generations_url: Url,
image_edits_url: Url,
search_url: Url,
session_identity: Option<CodexSessionIdentity>,
session_transport_fallbacks: CodexSessionTransportFallbacks,
session_transport_recovery: CodexSessionTransportRecovery,
stream_max_retries: u32,
}
@@ -165,6 +174,8 @@ impl CodexProvider {
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let image_edits_url = Url::parse(&endpoint_url(&base_url, CODEX_IMAGE_EDITS_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let search_url = Url::parse(&endpoint_url(&base_url, CODEX_ALPHA_SEARCH_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let client =
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
Ok(Self {
@@ -176,8 +187,9 @@ impl CodexProvider {
responses_url,
image_generations_url,
image_edits_url,
search_url,
session_identity: None,
session_transport_fallbacks: CodexSessionTransportFallbacks::default(),
session_transport_recovery: CodexSessionTransportRecovery::default(),
stream_max_retries,
})
}
@@ -208,23 +220,35 @@ impl Provider for CodexProvider {
self.catalog.catalog_generation()
}
fn request_observation(&self, operation: &Operation) -> ProviderRequestObservation {
fn request_observation(
&self,
operation: &Operation,
client_api_key_id: &gateway_core::policy::ClientApiKeyId,
) -> ProviderRequestObservation {
let Operation::Generate(request) = operation else {
return ProviderRequestObservation::default();
};
let (semantics, reasoning_effort) = encode_generate_request(request, "observability")
.map(|encoded| {
let semantics = encoded.semantics();
let reasoning_effort = semantics.reasoning_effort.clone();
(semantics, reasoning_effort)
})
.unwrap_or_default();
let Ok(encoded) = encode_generate_request(request, "observability") else {
return ProviderRequestObservation::default();
};
let semantics = encoded.semantics();
let reasoning_effort = semantics.reasoning_effort.clone();
let previous_response_id = encoded.previous_response_id();
let continuation = ContinuationRequestObservation {
affinity_hash: derive_codex_session_affinity(&encoded, client_api_key_id)
.map(|affinity| affinity.persistence_hash().to_owned()),
previous_response_id_hash: previous_response_id.map(|response_id| {
derive_previous_response_id_hash(response_id, client_api_key_id)
}),
requested: previous_response_id.is_some(),
};
ProviderRequestObservation {
reasoning_effort,
reasoning_preset: semantics.reasoning_preset.map(str::to_owned),
request_kind: semantics.request_kind,
subagent_kind: semantics.subagent_kind,
compact: semantics.compact,
continuation,
}
}
@@ -268,6 +292,9 @@ impl Provider for CodexProvider {
if let Operation::GenerateImage(image) = request.operation() {
return self.execute_image(image, candidate, context).await;
}
if let Operation::Search(search) = request.operation() {
return self.execute_search(search, candidate, context).await;
}
let Operation::Generate(generate) = request.operation() else {
return Err(provider_error(
ProviderErrorKind::Unsupported,
@@ -293,15 +320,20 @@ impl Provider for CodexProvider {
if let Some(identity) = &self.session_identity {
identity.prepare_local_conversation(&mut upstream_request);
}
if previous_session.as_ref().is_some_and(|state| {
same_client_turn(
state.client_turn_id.as_deref(),
if let Some(previous_session) = previous_session.as_ref() {
upstream_request.turn_state = if same_client_turn(
previous_session.client_turn_id.as_deref(),
upstream_request.client_turn_id.as_deref(),
)
}) {
upstream_request.turn_state = previous_session
.as_ref()
.and_then(|state| state.turn_state.clone());
) {
// 客户端可能携带 metadata-close 失败后才拿到的新状态;同一
// turn 内显式回传值比最后一次成功响应保存的状态更新。
upstream_request
.turn_state
.take()
.or_else(|| previous_session.turn_state.clone())
} else {
None
};
}
let session_affinity =
derive_codex_session_affinity(&upstream_request, context.client_api_key_ref());
@@ -385,7 +417,7 @@ impl Provider for CodexProvider {
continuation_recovery_action = "stop_proxy_recovery",
"OpenAI connection-local continuation replay was rejected before send"
);
return Err(continuation_replay_required_error());
return Err(continuation_replay_required_error("scope_unavailable"));
}
let previous_response_scope = match context.continuation_attempt() {
ContinuationAttempt::Native => native_scope,
@@ -410,7 +442,7 @@ impl Provider for CodexProvider {
if upstream_request.previous_response_id().is_some()
&& !account_scope.can_reuse_account_state()
{
return Err(continuation_replay_required_error());
return Err(continuation_replay_required_error("scope_unavailable"));
}
scope_request_to_account(
&mut upstream_request,
@@ -419,26 +451,46 @@ impl Provider for CodexProvider {
);
let requirement = transport_requirement(&upstream_request);
let requested_transport = selected_transport(&upstream_request);
let sticky_http_fallback = context.transport() == AttemptTransport::Default
&& requested_transport == CodexProviderTransport::PreferWebSocket
&& !requirement.requires_websocket()
&& session_affinity.as_ref().is_some_and(|affinity| {
self.session_transport_fallbacks
.is_http_only(affinity.key())
});
let session_transport_decision = if matches!(
context.transport(),
AttemptTransport::Default | AttemptTransport::Retry(_)
) && requested_transport
== CodexProviderTransport::PreferWebSocket
&& requirement.allows_session_transport_recovery()
&& let Some(affinity) = session_affinity.as_ref()
{
self.session_transport_recovery.decide(affinity.key())
} else {
CodexSessionTransportDecision::Default
};
let session_transport_action = session_transport_decision.action();
let session_websocket_failure_count = session_transport_decision.failure_count();
let session_http_cooldown_ms = session_transport_decision
.retry_after()
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX));
let mut websocket_connection_preference = WebSocketConnectionPreference::ReuseOrConnect;
let mut session_recovery_probe = None;
let transport = if requirement.requires_websocket() {
CodexProviderTransport::PreferWebSocket
} else {
match context.transport() {
AttemptTransport::Default if sticky_http_fallback => {
CodexProviderTransport::HttpOnly
AttemptTransport::Default | AttemptTransport::Retry(_) => {
match session_transport_decision {
CodexSessionTransportDecision::Default => requested_transport,
CodexSessionTransportDecision::FreshWebSocket { probe, .. } => {
websocket_connection_preference = WebSocketConnectionPreference::Fresh;
session_recovery_probe = Some(probe);
CodexProviderTransport::PreferWebSocket
}
CodexSessionTransportDecision::HttpSse { .. } => {
CodexProviderTransport::HttpOnly
}
}
}
AttemptTransport::Default => requested_transport,
AttemptTransport::Retry(_) => requested_transport,
AttemptTransport::Fallback => CodexProviderTransport::HttpOnly,
}
};
if sticky_http_fallback {
if let Some(session_transport_action) = session_transport_action {
tracing::info!(
request_id = %context.request_id(),
attempt_index = context.attempt_index().get(),
@@ -446,7 +498,11 @@ impl Provider for CodexProvider {
.as_ref()
.map_or("", CodexSessionAffinity::key_hash),
transport_requirement = requirement.as_str(),
"Reusing session-sticky OpenAI upstream HTTP fallback"
session_transport_action,
session_websocket_failure_count = session_websocket_failure_count.unwrap_or_default(),
session_http_cooldown_ms = session_http_cooldown_ms.unwrap_or_default(),
session_http_cooldown_present = session_http_cooldown_ms.is_some(),
"Applied OpenAI session transport recovery decision"
);
}
apply_transport(&mut upstream_request, transport);
@@ -495,14 +551,19 @@ impl Provider for CodexProvider {
output_started_at,
session_affinity_key,
session_affinity_key_hash,
session_transport_fallbacks: self.session_transport_fallbacks.clone(),
websocket_connection_preference,
session_transport_recovery: self.session_transport_recovery.clone(),
session_recovery_probe,
websocket_retry_count,
stream_max_retries: self.stream_max_retries,
session_capture,
});
let stream = ProviderStream::new(metadata, events, lease);
Ok(if allows_account_state_mutation {
stream.with_account_feedback(Arc::clone(&self.account_feedback))
stream.with_filtered_account_feedback(
Arc::clone(&self.account_feedback),
openai_failure_affects_account_score,
)
} else {
stream
})
@@ -391,7 +391,12 @@ pub(super) fn codex_error_observation(
)?
.with_status_code(status.as_u16());
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::Upstream(upstream)) => {
CodexClientError::WebSocket(error)
if matches!(error.classified(), CodexWebSocketExchangeError::Upstream(_)) =>
{
let CodexWebSocketExchangeError::Upstream(upstream) = error.classified() else {
unreachable!("websocket error was checked above")
};
observation = observation.with_status_code(upstream.status_code);
if let Some(request_id) = upstream.diagnostics.request_id.as_deref() {
observation =
@@ -1,104 +1,457 @@
//! OpenAI 根会话级上游传输降级状态。
//! OpenAI 根会话级上游传输恢复状态。
use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use std::time::Duration;
use gateway_core::provider_ports::ProviderSessionAffinityKey;
use tokio::time::Instant;
use uuid::Uuid;
use crate::credential::CODEX_ROOT_SESSION_TTL;
/// 未成功恢复的失败链需要跨过最长冷却,避免 4 小时档在半开前被清理。
const SESSION_RECOVERY_RETENTION: Duration = Duration::from_secs(8 * 60 * 60);
const HTTP_COOLDOWNS: [Duration; 4] = [
Duration::from_secs(5 * 60),
Duration::from_secs(30 * 60),
Duration::from_secs(60 * 60),
Duration::from_secs(4 * 60 * 60),
];
/// Provider 是进程级单例;限制不可显式观测结束的客户端会话占用。
const MAX_SESSION_HTTP_FALLBACKS: usize = 16_384;
const MAX_SESSION_RECOVERIES: usize = 16_384;
#[derive(Clone)]
pub(crate) struct CodexSessionTransportFallbacks {
state: Arc<Mutex<SessionTransportFallbackState>>,
ttl: Duration,
pub(crate) struct CodexSessionTransportRecovery {
state: Arc<Mutex<SessionTransportRecoveryState>>,
http_cooldowns: [Duration; 4],
retention: Duration,
capacity: usize,
}
#[derive(Default)]
struct SessionTransportFallbackState {
http_only: HashMap<ProviderSessionAffinityKey, Instant>,
struct SessionTransportRecoveryState {
sessions: HashMap<ProviderSessionAffinityKey, SessionCircuit>,
}
impl Default for CodexSessionTransportFallbacks {
enum SessionCircuit {
FreshPending {
failure_count: u8,
last_failure_at: Instant,
},
Open {
failure_count: u8,
last_failure_at: Instant,
until: Instant,
},
HalfOpen {
failure_count: u8,
last_failure_at: Instant,
probe_id: Uuid,
},
}
impl SessionCircuit {
const fn failure_count(&self) -> u8 {
match self {
Self::FreshPending { failure_count, .. }
| Self::Open { failure_count, .. }
| Self::HalfOpen { failure_count, .. } => *failure_count,
}
}
const fn last_failure_at(&self) -> Instant {
match self {
Self::FreshPending {
last_failure_at, ..
}
| Self::Open {
last_failure_at, ..
}
| Self::HalfOpen {
last_failure_at, ..
} => *last_failure_at,
}
}
}
impl Default for CodexSessionTransportRecovery {
fn default() -> Self {
Self::with_limits(CODEX_ROOT_SESSION_TTL, MAX_SESSION_HTTP_FALLBACKS)
Self {
state: Arc::new(Mutex::new(SessionTransportRecoveryState::default())),
http_cooldowns: HTTP_COOLDOWNS,
retention: SESSION_RECOVERY_RETENTION,
capacity: MAX_SESSION_RECOVERIES,
}
}
}
impl CodexSessionTransportFallbacks {
fn with_limits(ttl: Duration, capacity: usize) -> Self {
debug_assert!(!ttl.is_zero());
debug_assert!(capacity > 0);
#[derive(Default)]
pub(crate) enum CodexSessionTransportDecision {
#[default]
Default,
FreshWebSocket {
failure_count: u8,
probe: CodexSessionWebSocketProbe,
},
HttpSse {
reason: SessionHttpFallbackReason,
failure_count: u8,
retry_after: Option<Duration>,
},
}
impl CodexSessionTransportDecision {
pub(crate) const fn action(&self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::FreshWebSocket { .. } => Some("fresh_websocket_probe"),
Self::HttpSse {
reason: SessionHttpFallbackReason::Cooldown,
..
} => Some("http_cooldown"),
Self::HttpSse {
reason: SessionHttpFallbackReason::ProbeInFlight,
..
} => Some("http_probe_in_flight"),
}
}
pub(crate) const fn failure_count(&self) -> Option<u8> {
match self {
Self::Default => None,
Self::FreshWebSocket { failure_count, .. } | Self::HttpSse { failure_count, .. } => {
Some(*failure_count)
}
}
}
pub(crate) const fn retry_after(&self) -> Option<Duration> {
match self {
Self::HttpSse { retry_after, .. } => *retry_after,
Self::Default | Self::FreshWebSocket { .. } => None,
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum SessionWebSocketFallback {
RetryBudgetExhausted,
UpgradeRequired,
}
impl SessionWebSocketFallback {
const fn minimum_failure_count(self) -> u8 {
match self {
Self::RetryBudgetExhausted => 2,
Self::UpgradeRequired => 5,
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum SessionHttpFallbackReason {
Cooldown,
ProbeInFlight,
}
#[derive(Clone, Copy)]
pub(crate) struct CodexSessionRecoveryTransition {
failure_count: u8,
cooldown: Option<Duration>,
}
impl CodexSessionRecoveryTransition {
pub(crate) const fn action(self) -> &'static str {
if self.cooldown.is_some() {
"http_cooldown"
} else {
"fresh_websocket_next"
}
}
pub(crate) const fn failure_count(self) -> u8 {
self.failure_count
}
pub(crate) const fn cooldown(self) -> Option<Duration> {
self.cooldown
}
}
/// 一次根会话级 fresh WebSocket 许可。
///
/// 许可未得到明确结果就被释放时,恢复状态会重新允许下一次半开探测。
pub(crate) struct CodexSessionWebSocketProbe {
recovery: CodexSessionTransportRecovery,
key: ProviderSessionAffinityKey,
probe_id: Uuid,
armed: bool,
}
impl CodexSessionWebSocketProbe {
fn new(
recovery: CodexSessionTransportRecovery,
key: ProviderSessionAffinityKey,
probe_id: Uuid,
) -> Self {
Self {
state: Arc::new(Mutex::new(SessionTransportFallbackState::default())),
ttl,
capacity,
recovery,
key,
probe_id,
armed: true,
}
}
/// 返回该根会话是否已粘性切到 HTTP,并为活跃会话续期。
pub(crate) fn is_http_only(&self, key: &ProviderSessionAffinityKey) -> bool {
self.is_http_only_at(key, Instant::now())
pub(crate) fn succeed(mut self) -> bool {
let recovered = self.recovery.record_probe_success(&self.key, self.probe_id);
self.armed = false;
recovered
}
/// 在当前 WebSocket 重试预算耗尽后,为根会话启用粘性 HTTP。
///
/// 返回 `true` 表示本次发生了禁用状态转换;已禁用会话只续期。
pub(crate) fn disable_websocket(&self, key: &ProviderSessionAffinityKey) -> bool {
self.disable_websocket_at(key, Instant::now())
pub(crate) fn post_send_failed(mut self) -> CodexSessionRecoveryTransition {
let transition = self.recovery.record_post_send_failure(&self.key);
self.armed = false;
transition
}
fn is_http_only_at(&self, key: &ProviderSessionAffinityKey, now: Instant) -> bool {
pub(crate) fn fallback(
mut self,
fallback: SessionWebSocketFallback,
) -> CodexSessionRecoveryTransition {
let transition = self.recovery.record_websocket_fallback(&self.key, fallback);
self.armed = false;
transition
}
}
impl Drop for CodexSessionWebSocketProbe {
fn drop(&mut self) {
if self.armed {
self.recovery.cancel_probe(&self.key, self.probe_id);
}
}
}
impl CodexSessionTransportRecovery {
pub(crate) fn decide(&self, key: &ProviderSessionAffinityKey) -> CodexSessionTransportDecision {
self.decide_at(key, Instant::now())
}
pub(crate) fn record_post_send_failure(
&self,
key: &ProviderSessionAffinityKey,
) -> CodexSessionRecoveryTransition {
self.record_post_send_failure_at(key, Instant::now())
}
pub(crate) fn record_websocket_fallback(
&self,
key: &ProviderSessionAffinityKey,
fallback: SessionWebSocketFallback,
) -> CodexSessionRecoveryTransition {
self.record_websocket_fallback_at(key, fallback, Instant::now())
}
fn decide_at(
&self,
key: &ProviderSessionAffinityKey,
now: Instant,
) -> CodexSessionTransportDecision {
let mut state = self.lock();
let Some(last_seen) = state.http_only.get_mut(key) else {
return false;
let stale = state.sessions.get(key).is_some_and(|circuit| {
now.saturating_duration_since(circuit.last_failure_at()) >= self.retention
});
if stale {
state.sessions.remove(key);
return CodexSessionTransportDecision::Default;
}
let Some(circuit) = state.sessions.get_mut(key) else {
return CodexSessionTransportDecision::Default;
};
if now.saturating_duration_since(*last_seen) >= self.ttl {
state.http_only.remove(key);
return false;
match circuit {
SessionCircuit::Open {
failure_count,
until,
..
} if now < *until => CodexSessionTransportDecision::HttpSse {
reason: SessionHttpFallbackReason::Cooldown,
failure_count: *failure_count,
retry_after: Some(until.saturating_duration_since(now)),
},
SessionCircuit::HalfOpen { failure_count, .. } => {
CodexSessionTransportDecision::HttpSse {
reason: SessionHttpFallbackReason::ProbeInFlight,
failure_count: *failure_count,
retry_after: None,
}
}
SessionCircuit::FreshPending {
failure_count,
last_failure_at,
}
| SessionCircuit::Open {
failure_count,
last_failure_at,
..
} => {
let failure_count = *failure_count;
let last_failure_at = *last_failure_at;
let probe_id = Uuid::new_v4();
*circuit = SessionCircuit::HalfOpen {
failure_count,
last_failure_at,
probe_id,
};
CodexSessionTransportDecision::FreshWebSocket {
failure_count,
probe: CodexSessionWebSocketProbe::new(self.clone(), key.clone(), probe_id),
}
}
}
*last_seen = now;
true
}
fn disable_websocket_at(&self, key: &ProviderSessionAffinityKey, now: Instant) -> bool {
fn record_post_send_failure_at(
&self,
key: &ProviderSessionAffinityKey,
now: Instant,
) -> CodexSessionRecoveryTransition {
let mut state = self.lock();
let already_disabled = state
.http_only
.get(key)
.is_some_and(|last_seen| now.saturating_duration_since(*last_seen) < self.ttl);
if !already_disabled && !state.http_only.contains_key(key) {
let failure_count = self.next_failure_count(state.sessions.get(key), now);
if !state.sessions.contains_key(key) {
self.make_room(&mut state, now);
}
state.http_only.insert(key.clone(), now);
!already_disabled
if failure_count == 1 {
state.sessions.insert(
key.clone(),
SessionCircuit::FreshPending {
failure_count,
last_failure_at: now,
},
);
return CodexSessionRecoveryTransition {
failure_count,
cooldown: None,
};
}
let cooldown = self.cooldown_for(failure_count);
state.sessions.insert(
key.clone(),
SessionCircuit::Open {
failure_count,
last_failure_at: now,
until: now + cooldown,
},
);
CodexSessionRecoveryTransition {
failure_count,
cooldown: Some(cooldown),
}
}
fn make_room(&self, state: &mut SessionTransportFallbackState, now: Instant) {
if state.http_only.len() < self.capacity {
fn record_websocket_fallback_at(
&self,
key: &ProviderSessionAffinityKey,
fallback: SessionWebSocketFallback,
now: Instant,
) -> CodexSessionRecoveryTransition {
let mut state = self.lock();
let failure_count = self
.next_failure_count(state.sessions.get(key), now)
.max(fallback.minimum_failure_count());
if !state.sessions.contains_key(key) {
self.make_room(&mut state, now);
}
let cooldown = self.cooldown_for(failure_count);
state.sessions.insert(
key.clone(),
SessionCircuit::Open {
failure_count,
last_failure_at: now,
until: now + cooldown,
},
);
CodexSessionRecoveryTransition {
failure_count,
cooldown: Some(cooldown),
}
}
fn next_failure_count(&self, circuit: Option<&SessionCircuit>, now: Instant) -> u8 {
circuit.map_or(1, |circuit| {
if now.saturating_duration_since(circuit.last_failure_at()) < self.retention {
circuit.failure_count().saturating_add(1)
} else {
1
}
})
}
fn cooldown_for(&self, failure_count: u8) -> Duration {
let index = usize::from(failure_count.saturating_sub(2)).min(self.http_cooldowns.len() - 1);
self.http_cooldowns[index]
}
fn record_probe_success(&self, key: &ProviderSessionAffinityKey, probe_id: Uuid) -> bool {
let mut state = self.lock();
let owns_probe = matches!(
state.sessions.get(key),
Some(SessionCircuit::HalfOpen {
probe_id: active_probe_id,
..
}) if *active_probe_id == probe_id
);
if owns_probe {
state.sessions.remove(key);
}
owns_probe
}
fn cancel_probe(&self, key: &ProviderSessionAffinityKey, probe_id: Uuid) {
let mut state = self.lock();
let Some(SessionCircuit::HalfOpen {
failure_count,
last_failure_at,
probe_id: active_probe_id,
}) = state.sessions.get(key)
else {
return;
};
if *active_probe_id != probe_id {
return;
}
state
.http_only
.retain(|_, last_seen| now.saturating_duration_since(*last_seen) < self.ttl);
if state.http_only.len() < self.capacity {
let failure_count = *failure_count;
let last_failure_at = *last_failure_at;
state.sessions.insert(
key.clone(),
SessionCircuit::FreshPending {
failure_count,
last_failure_at,
},
);
}
fn make_room(&self, state: &mut SessionTransportRecoveryState, now: Instant) {
if state.sessions.len() < self.capacity {
return;
}
state.sessions.retain(|_, circuit| {
now.saturating_duration_since(circuit.last_failure_at()) < self.retention
});
if state.sessions.len() < self.capacity {
return;
}
let oldest = state
.http_only
.sessions
.iter()
.min_by_key(|(_, last_seen)| **last_seen)
.min_by_key(|(_, circuit)| circuit.last_failure_at())
.map(|(key, _)| key.clone());
if let Some(oldest) = oldest {
state.http_only.remove(&oldest);
state.sessions.remove(&oldest);
}
}
fn lock(&self) -> MutexGuard<'_, SessionTransportFallbackState> {
fn lock(&self) -> MutexGuard<'_, SessionTransportRecoveryState> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -24,7 +24,9 @@ use serde_json::{Value, map::Map};
use thiserror::Error;
use uuid::Uuid;
use crate::transport::protocol::responses::{CodexResponsesRequest, TransportRequirement};
use crate::transport::protocol::responses::{
CodexResponsesRequest, TransportRequirement, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY,
};
use super::diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamFailure, CodexUpstreamSendPhase};
use super::response_meta::CodexResponseMetadata;
@@ -313,7 +315,12 @@ impl CodexClientError {
rate_limit_headers,
*send_phase,
)),
Self::WebSocket(CodexWebSocketExchangeError::Upstream(upstream)) => {
Self::WebSocket(error)
if matches!(error.classified(), CodexWebSocketExchangeError::Upstream(_)) =>
{
let CodexWebSocketExchangeError::Upstream(upstream) = error.classified() else {
unreachable!("websocket error was checked above")
};
Some(CodexUpstreamFailure::from_response(
StatusCode::from_u16(upstream.status_code).unwrap_or(StatusCode::BAD_GATEWAY),
&upstream.body,
@@ -541,7 +548,7 @@ pub struct CodexBackendStreamingResponse {
pub transport: CodexBackendTransport,
/// WebSocket 响应所绑定的连接;HTTP transport 为 `None`。
pub websocket_connection_id: Option<Uuid>,
/// 响应头里的最新 turn state。
/// 响应头或 metadata 事件里最先确认的 turn state。
pub turn_state: Option<String>,
/// 上游透传的 `set-cookie` 列表。
pub set_cookie_headers: Vec<String>,
@@ -755,32 +762,33 @@ pub(super) async fn read_error_response_body(
pub(super) fn websocket_upstream_request(request: &CodexResponsesRequest) -> CodexResponsesRequest {
let mut request = request.clone();
project_responses_lite_to_ws_metadata(&mut request);
stamp_ws_stream_request_start_ms(&mut request);
project_websocket_client_metadata(&mut request);
request
}
fn project_responses_lite_to_ws_metadata(request: &mut CodexResponsesRequest) {
let Some(responses_lite) = request.responses_lite.clone() else {
return;
};
let mut metadata = match request.client_metadata() {
Some(Value::Object(metadata)) => metadata.clone(),
None => Map::new(),
Some(_) => return,
};
metadata
.entry(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string())
.or_insert(Value::String(responses_lite));
request.set_client_metadata(Some(Value::Object(metadata)));
}
fn stamp_ws_stream_request_start_ms(request: &mut CodexResponsesRequest) {
fn project_websocket_client_metadata(request: &mut CodexResponsesRequest) {
let mut metadata = match request.client_metadata() {
Some(Value::Object(metadata)) => metadata.clone(),
None => Map::new(),
Some(_) => return,
};
if let Some(responses_lite) = request.responses_lite.clone() {
metadata
.entry(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_owned())
.or_insert(Value::String(responses_lite));
}
let turn_state = request.turn_state.clone();
match turn_state {
Some(turn_state) => {
metadata.insert(
X_CODEX_TURN_STATE_CLIENT_METADATA_KEY.to_owned(),
Value::String(turn_state),
);
}
None => {
metadata.remove(X_CODEX_TURN_STATE_CLIENT_METADATA_KEY);
}
}
metadata.insert(
X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY.to_string(),
Value::String(now_unix_timestamp_millis().to_string()),
@@ -848,10 +856,7 @@ pub(super) fn websocket_success_decision(
pub(super) fn http_fallback_decision(
error: &CodexWebSocketExchangeError,
) -> CodexTransportDecision {
match error {
CodexWebSocketExchangeError::FastPathTimeout { .. } => {
CodexTransportDecision::Http2WebSocketBudgetExhausted
}
match error.classified() {
CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy => {
CodexTransportDecision::Http2BreakerOpen
@@ -39,6 +39,11 @@ impl CodexBackendClient {
context.installation_id,
)?;
insert_optional_protocol_header(&mut headers, "x-codex-image-turn-id", image_turn_id);
insert_optional_protocol_header(
&mut headers,
"x-codex-turn-metadata",
context.turn_metadata,
);
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(context.request_id)?,
@@ -38,15 +38,25 @@ use crate::transport::{
websocket::{
CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketPool,
CodexWebSocketPoolKey, CodexWebSocketStreamingExchange, DEFAULT_INITIAL_EVENT_TIMEOUT,
WEBSOCKET_FAST_PATH_BUDGET, WebSocketOriginBreaker, WebSocketPoolDecision,
execute_prepared_response_create_request_stream, post_send_ambiguous,
prepare_response_create_request_with_pool, websocket_audit_dir,
WEBSOCKET_FAST_PATH_BUDGET, WebSocketFastPath, WebSocketOriginBreaker,
WebSocketPoolDecision, execute_prepared_response_create_request_stream,
post_send_ambiguous, prepare_response_create_request_with_pool, websocket_audit_dir,
write_websocket_audit_artifact_from_env,
},
};
use super::client::*;
/// 单次 Responses WebSocket opening 是否可以使用会话连接池。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum WebSocketConnectionPreference {
/// 优先复用匹配的空闲连接,否则建立并登记一条新连接。
#[default]
ReuseOrConnect,
/// 绕过连接池建立独立连接,用于根会话恢复探针。
Fresh,
}
impl CodexBackendClient {
/// 构造客户端。
pub fn new(
@@ -188,9 +198,16 @@ impl CodexBackendClient {
request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
pool_account_id: Option<&str>,
connection_preference: WebSocketConnectionPreference,
) -> CodexClientResult<CodexBackendStreamingResponse> {
let prepared = self
.prepare_response_transport(request, context, pool_account_id, false, true)
.prepare_response_transport(
request,
context,
pool_account_id,
connection_preference,
true,
)
.await?;
self.create_response_stream_with_prepared(request, context, prepared)
.await
@@ -204,18 +221,24 @@ impl CodexBackendClient {
context: CodexRequestContext<'_>,
pool_account_id: Option<&str>,
) -> CodexClientResult<PreparedResponseTransport> {
self.prepare_response_transport(request, context, pool_account_id, false, false)
.await
self.prepare_response_transport(
request,
context,
pool_account_id,
WebSocketConnectionPreference::ReuseOrConnect,
false,
)
.await
}
/// 与 [`Self::prepare_response_transport_with_pool_account`] 相同;
/// `force_fresh` 时绕过连接池,为连接寿命限制重试强制新建连接。
/// fresh 偏好会绕过连接池,为受控恢复探针强制新建连接。
async fn prepare_response_transport(
&self,
request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
pool_account_id: Option<&str>,
force_fresh: bool,
connection_preference: WebSocketConnectionPreference,
defer_websocket_recovery: bool,
) -> CodexClientResult<PreparedResponseTransport> {
let requirement = transport_requirement(request);
@@ -256,10 +279,11 @@ impl CodexBackendClient {
let pool_key =
self.websocket_pool_key(request, context, pool_account_id, &connection_profile);
let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key);
let pool = if force_fresh {
None
} else {
self.websocket_pool.as_deref().zip(pool_key)
let pool = match connection_preference {
WebSocketConnectionPreference::ReuseOrConnect => {
self.websocket_pool.as_deref().zip(pool_key)
}
WebSocketConnectionPreference::Fresh => None,
};
let fast_path_budget = match requirement {
TransportRequirement::PersistedContinuation | TransportRequirement::NewChain => {
@@ -282,7 +306,31 @@ impl CodexBackendClient {
)
.await;
let prepared = match prepared {
Ok(prepared) => prepared,
Ok(WebSocketFastPath::Ready(prepared)) => prepared,
Ok(WebSocketFastPath::Missed) => {
let decision = CodexTransportDecision::Http2WebSocketBudgetExhausted;
let wait_ms = elapsed_duration_millis(prepare_started_at.elapsed());
tracing::info!(
request_id = %context.request_id,
account_id = pool_account_id.or(context.account_id).unwrap_or_default(),
transport_requirement = requirement.as_str(),
transport_decision = decision.as_str(),
transport_decision_wait_ms = wait_ms,
websocket_preconnect_continues = pool_log_context.is_some(),
"WebSocket fast path missed; using same-account HTTP"
);
return Ok(PreparedResponseTransport {
requirement,
route: PreparedResponseRoute::Http,
metrics: CodexTransportMetrics {
decision: Some(decision),
ws_connect_ms: None,
transport_decision_wait_ms: Some(wait_ms),
..CodexTransportMetrics::default()
},
defer_websocket_recovery,
});
}
Err(error)
if (!defer_websocket_recovery || error.requires_immediate_pool_fallback())
&& requirement.allows_pre_send_http_fallback()
@@ -434,7 +482,13 @@ impl CodexBackendClient {
);
drop(exchange);
match self
.prepare_response_transport(request, context, None, true, false)
.prepare_response_transport(
request,
context,
None,
WebSocketConnectionPreference::Fresh,
false,
)
.await
{
Ok(PreparedResponseTransport {
@@ -628,10 +682,12 @@ impl CodexBackendClient {
.local_conversation_id
.as_deref()
.or(request.previous_response_id())?;
Some(
CodexWebSocketPoolKey::new(&self.base_url, account_id, conversation_id)
.with_connection_profile(connection_profile),
)
let mut key = CodexWebSocketPoolKey::new(&self.base_url, account_id, conversation_id)
.with_connection_profile(connection_profile);
if let Some(connection_id) = request.downstream_websocket_connection_id.as_deref() {
key = key.with_downstream_connection_id(connection_id);
}
Some(key)
}
/// 获取后端模型目录条目。
@@ -58,6 +58,7 @@ const PERSISTABLE_UPSTREAM_CODES: &[&str] = &[
"rate_limit_exceeded",
"rate_limit_reached",
"refresh_token_invalidated",
"server_error",
"server_is_overloaded",
"slow_down",
"token_expired",
@@ -4,6 +4,8 @@ pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/codex/images/generations";
/// `/codex/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
/// `/codex/alpha/search`
pub const CODEX_ALPHA_SEARCH_PATH: &str = "/codex/alpha/search";
/// `/api/codex/usage`
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
@@ -21,6 +21,7 @@ pub mod tls;
pub mod usage;
pub mod websocket;
pub(crate) use self::client_sse::WebSocketConnectionPreference;
pub use self::{
canonical::{CodexCanonicalDecoder, CodexCanonicalError},
catalog::{
@@ -36,8 +37,8 @@ pub use self::{
},
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CODEX_USAGE_API_PATH, WHAM_PROFILE_STATISTICS_PATH,
CODEX_ALPHA_SEARCH_PATH, CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH,
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, WHAM_PROFILE_STATISTICS_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_PATH,
WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
},
@@ -10,6 +10,8 @@ use serde_json::{Map, Value};
/// 官方 Codex 客户端据此触发完整历史重放的稳定错误码。
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
/// Responses WebSocket 用于回传同一 turn 不透明状态的官方 client metadata 键。
pub(crate) const X_CODEX_TURN_STATE_CLIENT_METADATA_KEY: &str = "x-codex-turn-state";
/// 本地生成 history unavailable 错误时使用的官方提示文本。
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
"Previous response was not found. Retrying the full request.";
@@ -82,6 +84,8 @@ pub struct CodexResponsesRequest {
pub memgen_request: Option<String>,
/// Codex window ID。
pub codex_window_id: Option<String>,
/// 代理分配的下游 WebSocket 连接 ID,仅用于隔离本地连接池通道。
pub downstream_websocket_connection_id: Option<String>,
/// 父线程 ID。
pub parent_thread_id: Option<String>,
/// 已知 previous response 的持久化范围,仅用于本地 transport 校验。
@@ -162,6 +166,11 @@ impl TransportRequirement {
matches!(self, Self::NewChain)
}
/// 会话级恢复状态是否可以为请求选择 fresh WebSocket 或临时 HTTP。
pub fn allows_session_transport_recovery(self) -> bool {
matches!(self, Self::NewChain)
}
/// 用于审计与遥测的稳定名称。
pub fn as_str(self) -> &'static str {
match self {
@@ -193,8 +202,7 @@ pub fn transport_requirement(request: &CodexResponsesRequest) -> TransportRequir
TransportRequirement::ExternalUnknown
}
},
None if request.use_websocket => TransportRequirement::NewChain,
None => TransportRequirement::HttpRequired,
None => TransportRequirement::NewChain,
}
}
@@ -515,6 +523,7 @@ impl CodexResponsesRequest {
responses_lite: None,
memgen_request: None,
codex_window_id: None,
downstream_websocket_connection_id: None,
parent_thread_id: None,
previous_response_scope: None,
}
@@ -645,10 +654,9 @@ impl CodexResponsesRequest {
/// 返回请求语义与 child transport 隔离所用的子代理区分值。
///
/// Codex 原生请求在 turn metadata 中声明 `subagent_kind`;网关的
/// `/responses/review` 等入口则通过 `client_metadata.x-openai-subagent`
/// 传递给上游。它不拆分根会话的账号首选项;`thread_spawn` 仍用它派生独立
/// WebSocket/continuation transport identity。
/// Codex 原生请求在 turn metadata 中声明 `subagent_kind`;兼容客户端也可通过
/// `client_metadata.x-openai-subagent` 声明同一语义。它不拆分根会话的账号首选项;
/// `thread_spawn` 仍用它派生独立 WebSocket/continuation transport identity。
pub fn subagent_kind(&self) -> Option<String> {
self.semantics()
.subagent_kind
@@ -110,9 +110,9 @@ fn is_internal_websocket_event(event: &str) -> bool {
event == "codex.rate_limits"
}
/// 提取 `response.metadata` 帧中的字符串响应头。
/// 提取 Responses WebSocket metadata 帧中的字符串响应头。
pub fn websocket_metadata_headers(value: &Value) -> Vec<(String, String)> {
if value.get("type").and_then(Value::as_str) != Some("response.metadata") {
if !is_websocket_metadata_event(websocket_event_type(value)) {
return Vec::new();
}
value
@@ -124,24 +124,18 @@ pub fn websocket_metadata_headers(value: &Value) -> Vec<(String, String)> {
.collect()
}
/// 从 `response.metadata` 帧中提取 `x-codex-turn-state`。
/// 从 Responses WebSocket metadata 帧中提取 `x-codex-turn-state`。
pub fn websocket_metadata_turn_state(value: &Value) -> Option<String> {
if value.get("type").and_then(Value::as_str) != Some("response.metadata") {
return None;
}
websocket_metadata_headers(value)
.into_iter()
.find_map(|(name, value)| {
name.eq_ignore_ascii_case("x-codex-turn-state")
.then_some(value)
})
.or_else(|| {
value
.pointer("/metadata/turn_state")
.or_else(|| value.get("turn_state"))
.and_then(Value::as_str)
.map(ToString::to_string)
})
}
fn is_websocket_metadata_event(event: Option<&str>) -> bool {
matches!(event, Some("response.metadata" | "codex.response.metadata"))
}
/// 上游 WebSocket 连接寿命限制错误码。
@@ -7,9 +7,12 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::transport::protocol::responses::CodexResponsesRequest;
use crate::transport::protocol::responses::{
CodexResponsesRequest, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY,
};
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
@@ -144,8 +147,16 @@ struct ExtractedRequestContext {
impl ExtractedRequestContext {
fn from_body(body: &Map<String, Value>) -> Self {
let client_metadata = body.get("client_metadata").and_then(Value::as_object);
Self {
turn_state: body_string(body, "turnState"),
// 官方 downstream WebSocket 无法逐帧更新 HTTP header,因此把
// response.metadata 返回的 turn state 放回下一帧 client_metadata。
// Provider 仍会在账号/turn 归属确定后决定是否允许复用该状态。
turn_state: body_string(body, "turnState").or_else(|| {
client_metadata.and_then(|metadata| {
string_value(metadata.get(X_CODEX_TURN_STATE_CLIENT_METADATA_KEY))
})
}),
turn_metadata: body_string(body, "turnMetadata"),
beta_features: body_string(body, "betaFeatures"),
version: body_string(body, "version"),
@@ -156,15 +167,15 @@ impl ExtractedRequestContext {
session_id: body_string(body, "session_id"),
thread_id: body_string(body, "thread_id"),
client_request_id: body_string(body, "x-client-request-id"),
turn_id: body_string(body, "turn_id"),
turn_id: body_string(body, "turn_id").or_else(|| {
client_metadata
.and_then(|metadata| string_value(metadata.get(TURN_ID_CLIENT_METADATA_KEY)))
}),
// Responses Lite 在 WebSocket body 中的这个键是官方 header 投影,
// 不是普通上下文字段的 metadata 回退。
responses_lite: body
.get("client_metadata")
.and_then(Value::as_object)
.and_then(|metadata| {
string_value(metadata.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY))
}),
responses_lite: client_metadata.and_then(|metadata| {
string_value(metadata.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY))
}),
// Memory consolidation 只由官方请求头提供;body 中同名字段不参与
// transport 事实提取。
memgen_request: None,
@@ -427,7 +438,11 @@ fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String>
.map(ToOwned::to_owned)
}
fn scope_turn_metadata(raw: &str, installation_id: &str, cross_account: bool) -> Option<String> {
pub(crate) fn scope_turn_metadata(
raw: &str,
installation_id: &str,
cross_account: bool,
) -> Option<String> {
let Ok(Value::Object(mut metadata)) = serde_json::from_str::<Value>(raw) else {
return (!cross_account).then(|| raw.to_owned());
};
@@ -507,6 +522,9 @@ fn apply_protocol_context(request: &mut CodexResponsesRequest, context: &Map<Str
.or_else(|| request.include_timing_metrics.take());
request.codex_window_id =
context_string(context, "codex_window_id").or_else(|| request.codex_window_id.take());
request.downstream_websocket_connection_id =
context_string(context, "downstream_websocket_connection_id")
.or_else(|| request.downstream_websocket_connection_id.take());
request.parent_thread_id =
context_string(context, "parent_thread_id").or_else(|| request.parent_thread_id.take());
let prompt_cache_key = request.prompt_cache_key().map(ToOwned::to_owned);
@@ -24,11 +24,19 @@ use super::{
WebSocketPoolConnectLease, WebSocketPoolConnectOutcome, WebSocketPoolDecision,
WebSocketPoolLease,
},
pump::{PumpKeepalive, PumpLogContext, PumpedWebSocket},
pump::{PumpKeepalive, PumpLogContext, PumpedWebSocket, transport_metric_reason},
};
pub(crate) const WEBSOCKET_FAST_PATH_BUDGET: Duration = Duration::from_millis(800);
/// WebSocket 快路径的控制流结果;预算未命中不代表连接失败。
pub(crate) enum WebSocketFastPath<T> {
/// 在前台预算内获得结果。
Ready(T),
/// 前台停止等待;池化 opening 可以继续在后台完成。
Missed,
}
/// 尚未发送 `response.create` 的 WebSocket。只有该类型可以安全切换到 HTTP。
pub(crate) struct PreparedWebSocket {
connection: PooledWebSocketConnection,
@@ -104,7 +112,7 @@ pub(crate) async fn prepare_response_create_request_with_pool(
fast_path_budget: Option<Duration>,
require_pool: bool,
fallback_initial_event_timeout: Option<Duration>,
) -> Result<PreparedWebSocket, CodexWebSocketExchangeError> {
) -> Result<WebSocketFastPath<PreparedWebSocket>, CodexWebSocketExchangeError> {
let decision_started_at = Instant::now();
let Some((pool, key)) = pool else {
if require_pool || !request.continuation().permits_fresh_connection() {
@@ -142,7 +150,7 @@ pub(crate) async fn prepare_response_create_request_with_pool(
PreviousResponseUnavailableReason::LatestResponseMismatch,
));
}
Ok(PreparedWebSocket {
Ok(WebSocketFastPath::Ready(PreparedWebSocket {
connection: *connection,
binding: PoolBinding::Pooled {
lease: Box::new(lease),
@@ -152,7 +160,7 @@ pub(crate) async fn prepare_response_create_request_with_pool(
connect_elapsed: None,
decision_wait_elapsed: decision_started_at.elapsed(),
initial_event_timeout: pool.initial_event_timeout(),
})
}))
}
WebSocketPoolAcquire::Connect(connect_lease) => {
if !request.continuation().permits_fresh_connection() {
@@ -178,19 +186,21 @@ pub(crate) async fn prepare_response_create_request_with_pool(
PreviousResponseUnavailableReason::ConnectionBusy,
));
}
let outcome = wait_for_shared_connect(waiter, fast_path_budget).await?;
match outcome {
WebSocketPoolConnectOutcome::Ready => Err(continuation_unavailable(
PreviousResponseUnavailableReason::ConnectionBusy,
)),
WebSocketPoolConnectOutcome::Failed | WebSocketPoolConnectOutcome::Pending => {
Err(CodexWebSocketExchangeError::SharedConnectFailed)
}
match wait_for_shared_connect(waiter, fast_path_budget).await {
WebSocketFastPath::Missed => Ok(WebSocketFastPath::Missed),
WebSocketFastPath::Ready(WebSocketPoolConnectOutcome::Ready) => Err(
continuation_unavailable(PreviousResponseUnavailableReason::ConnectionBusy),
),
WebSocketFastPath::Ready(
WebSocketPoolConnectOutcome::Failed | WebSocketPoolConnectOutcome::Pending,
) => Err(CodexWebSocketExchangeError::SharedConnectFailed),
}
}
WebSocketPoolAcquire::Bypass(reason) => {
Err(continuation_unavailable(bypass_unavailable_reason(reason)))
}
WebSocketPoolAcquire::ContinuationLost(observation) => Err(continuation_unavailable(
PreviousResponseUnavailableReason::ReusedConnectionLost,
)
.with_connection_observation(observation)),
WebSocketPoolAcquire::Bypass(reason) => Err(bypass_unavailable_error(reason)),
}
}
@@ -201,7 +211,7 @@ async fn prepare_unpooled_websocket(
fast_path_budget: Option<Duration>,
initial_event_timeout: Option<Duration>,
decision_started_at: Instant,
) -> Result<PreparedWebSocket, CodexWebSocketExchangeError> {
) -> Result<WebSocketFastPath<PreparedWebSocket>, CodexWebSocketExchangeError> {
let permit = acquire_breaker_permit(breaker, origin_key)?;
let context = pump_log_context_from_connection(request.connection());
let connected = connect_with_budget(
@@ -211,14 +221,22 @@ async fn prepare_unpooled_websocket(
context,
)
.await;
let connected = match connected {
Ok(WebSocketFastPath::Ready(connection)) => Ok(connection),
Ok(WebSocketFastPath::Missed) => {
permit.fast_timeout();
return Ok(WebSocketFastPath::Missed);
}
Err(error) => Err(error),
};
let (connection, connect_elapsed) = finish_breaker_attempt(permit, connected)?;
Ok(PreparedWebSocket {
Ok(WebSocketFastPath::Ready(PreparedWebSocket {
connection,
binding: PoolBinding::Unpooled,
connect_elapsed: Some(connect_elapsed),
decision_wait_elapsed: decision_started_at.elapsed(),
initial_event_timeout,
})
}))
}
async fn prepare_pooled_websocket(
@@ -229,7 +247,7 @@ async fn prepare_pooled_websocket(
origin_key: &str,
fast_path_budget: Option<Duration>,
decision_started_at: Instant,
) -> Result<PreparedWebSocket, CodexWebSocketExchangeError> {
) -> Result<WebSocketFastPath<PreparedWebSocket>, CodexWebSocketExchangeError> {
let permit = match acquire_breaker_permit(breaker, origin_key) {
Ok(permit) => permit,
Err(error) => {
@@ -243,18 +261,20 @@ async fn prepare_pooled_websocket(
connect_lease,
permit,
);
let handoff = waiter.wait(fast_path_budget).await?;
Ok(PreparedWebSocket {
connection: *handoff.connection,
binding: PoolBinding::Pooled {
lease: Box::new(handoff.lease),
reused: false,
decision: WebSocketPoolDecision::new(),
},
connect_elapsed: Some(handoff.connect_elapsed),
decision_wait_elapsed: decision_started_at.elapsed(),
initial_event_timeout: pool.initial_event_timeout(),
})
match waiter.wait(fast_path_budget).await? {
WebSocketFastPath::Ready(handoff) => Ok(WebSocketFastPath::Ready(PreparedWebSocket {
connection: *handoff.connection,
binding: PoolBinding::Pooled {
lease: Box::new(handoff.lease),
reused: false,
decision: WebSocketPoolDecision::new(),
},
connect_elapsed: Some(handoff.connect_elapsed),
decision_wait_elapsed: decision_started_at.elapsed(),
initial_event_timeout: pool.initial_event_timeout(),
})),
WebSocketFastPath::Missed => Ok(WebSocketFastPath::Missed),
}
}
struct PooledWebSocketConnectWaiter {
@@ -273,7 +293,7 @@ impl PooledWebSocketConnectWaiter {
async fn wait(
&mut self,
fast_path_budget: Option<Duration>,
) -> Result<PooledWebSocketHandoff, CodexWebSocketExchangeError> {
) -> Result<WebSocketFastPath<PooledWebSocketHandoff>, CodexWebSocketExchangeError> {
let received = match fast_path_budget {
Some(budget) => {
let remaining = budget.saturating_sub(self.started_at.elapsed());
@@ -281,15 +301,15 @@ impl PooledWebSocketConnectWaiter {
Ok(received) => received,
Err(_) => {
self.fast_path_reporter.missed();
return Err(CodexWebSocketExchangeError::FastPathTimeout {
timeout: budget,
});
return Ok(WebSocketFastPath::Missed);
}
}
}
None => (&mut self.receiver).await,
};
received.map_err(|_| CodexWebSocketExchangeError::SharedConnectFailed)?
received
.map_err(|_| CodexWebSocketExchangeError::SharedConnectFailed)?
.map(WebSocketFastPath::Ready)
}
}
@@ -325,7 +345,7 @@ fn start_pooled_websocket_connect(
);
return;
}
result = connect_with_budget(&connection, keepalive, None, context) => result,
result = connect_websocket_connection(&connection, keepalive, context) => result,
};
match finish_breaker_attempt(permit, connected) {
Ok((connection, connect_elapsed)) => {
@@ -369,6 +389,7 @@ fn start_pooled_websocket_connect(
}
}
Err(error) => {
let connection_exit_reason = opening_failure_reason(&error);
let error_message = error.to_string();
// 先交付 opening 原始错误,避免连接池清理侵占前台 fast-path 预算。
let foreground_waiting = sender.send(Err(error)).is_ok();
@@ -378,6 +399,7 @@ fn start_pooled_websocket_connect(
conversation_id_hash = task_key.conversation_id_hash(),
ws_preconnect_duration_ms = duration_millis_u64(started_at.elapsed()),
foreground_waiting,
connection_exit_reason,
error = %error_message,
ws_preconnect_outcome = "failed",
"WebSocket pool connect finished"
@@ -392,6 +414,20 @@ fn start_pooled_websocket_connect(
}
}
fn opening_failure_reason(error: &CodexWebSocketExchangeError) -> &'static str {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_) => "request_build_failure",
CodexWebSocketExchangeError::Connect(error)
| CodexWebSocketExchangeError::Transport(error) => transport_metric_reason(error),
CodexWebSocketExchangeError::ConnectTimeout { .. } => "connect_timeout",
CodexWebSocketExchangeError::Upstream(_) => "upgrade_rejected",
CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy => "connect_suppressed",
CodexWebSocketExchangeError::SharedConnectFailed => "shared_connect_failed",
_ => "connect_failure",
}
}
fn duration_millis_u64(duration: Duration) -> u64 {
u64::try_from(duration.as_millis())
.unwrap_or(u64::MAX)
@@ -413,18 +449,32 @@ async fn connect_with_budget(
keepalive: PumpKeepalive,
fast_path_budget: Option<Duration>,
context: PumpLogContext,
) -> Result<WebSocketFastPath<(PooledWebSocketConnection, Duration)>, CodexWebSocketExchangeError> {
match fast_path_budget {
Some(budget) => {
let Ok(connected) = timeout(
budget,
connect_websocket_connection(connection, keepalive, context),
)
.await
else {
return Ok(WebSocketFastPath::Missed);
};
connected.map(WebSocketFastPath::Ready)
}
None => connect_websocket_connection(connection, keepalive, context)
.await
.map(WebSocketFastPath::Ready),
}
}
async fn connect_websocket_connection(
connection: &CodexWebSocketConnection,
keepalive: PumpKeepalive,
context: PumpLogContext,
) -> Result<(PooledWebSocketConnection, Duration), CodexWebSocketExchangeError> {
let started_at = Instant::now();
let connected = match fast_path_budget {
Some(budget) => timeout(
budget,
connect_pumped_websocket(connection, keepalive, context),
)
.await
.map_err(|_| CodexWebSocketExchangeError::FastPathTimeout { timeout: budget })?,
None => connect_pumped_websocket(connection, keepalive, context).await,
}?;
let (websocket, response) = connected;
let (websocket, response) = connect_pumped_websocket(connection, keepalive, context).await?;
Ok((
PooledWebSocketConnection {
websocket,
@@ -458,10 +508,6 @@ fn finish_breaker_attempt(
permit.succeed();
Ok(connection)
}
Err(error @ CodexWebSocketExchangeError::FastPathTimeout { .. }) => {
permit.fast_timeout();
Err(error)
}
Err(CodexWebSocketExchangeError::Upstream(upstream)) if upstream.status_code < 500 => {
// 账号或请求级 opening 响应证明 origin 可达,不得污染 transport 熔断器。
permit.succeed();
@@ -477,28 +523,29 @@ fn finish_breaker_attempt(
async fn wait_for_shared_connect(
waiter: pool::WebSocketPoolConnectWaiter,
fast_path_budget: Option<Duration>,
) -> Result<WebSocketPoolConnectOutcome, CodexWebSocketExchangeError> {
) -> WebSocketFastPath<WebSocketPoolConnectOutcome> {
match fast_path_budget {
Some(budget) => {
let remaining = waiter.remaining_budget(budget);
timeout(remaining, waiter.wait())
.await
.map_err(|_| CodexWebSocketExchangeError::FastPathTimeout { timeout: budget })
match timeout(remaining, waiter.wait()).await {
Ok(outcome) => WebSocketFastPath::Ready(outcome),
Err(_) => WebSocketFastPath::Missed,
}
}
None => Ok(waiter.wait().await),
None => WebSocketFastPath::Ready(waiter.wait().await),
}
}
fn bypass_unavailable_reason(
reason: WebSocketPoolBypassReason,
) -> PreviousResponseUnavailableReason {
fn bypass_unavailable_error(reason: WebSocketPoolBypassReason) -> CodexWebSocketExchangeError {
match reason {
WebSocketPoolBypassReason::Busy => PreviousResponseUnavailableReason::ConnectionBusy,
WebSocketPoolBypassReason::Busy => {
continuation_unavailable(PreviousResponseUnavailableReason::ConnectionBusy)
}
WebSocketPoolBypassReason::Disabled | WebSocketPoolBypassReason::Cap => {
PreviousResponseUnavailableReason::PoolUnavailable
continuation_unavailable(PreviousResponseUnavailableReason::PoolUnavailable)
}
WebSocketPoolBypassReason::ContinuationNotFound => {
PreviousResponseUnavailableReason::FreshConnectionRequired
continuation_unavailable(PreviousResponseUnavailableReason::FreshConnectionRequired)
}
}
}
@@ -527,8 +574,13 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
created_at,
} = connection;
if let Err(error) = send_websocket_request(&websocket, request.payload_text()).await {
discard_after_send(websocket, lease).await;
return Err(post_send_ambiguous(error));
let observation = websocket
.observation()
.with_exit_reason("outbound_transport_error");
discard_after_send(websocket, lease, observation.clone()).await;
return Err(post_send_ambiguous(
error.with_connection_observation(observation),
));
}
let connection_local_available = lease.is_some();
let pool_return = lease.map(|lease| WebSocketStreamPoolReturn {
@@ -548,9 +600,13 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
Ok(exchange)
}
async fn discard_after_send(websocket: PumpedWebSocket, lease: Option<WebSocketPoolLease>) {
async fn discard_after_send(
websocket: PumpedWebSocket,
lease: Option<WebSocketPoolLease>,
observation: super::pump::WebSocketConnectionObservation,
) {
if let Some(lease) = lease {
lease.discard().await;
lease.discard_with_observation(observation).await;
}
websocket.close().await;
}
@@ -11,6 +11,7 @@ use crate::transport::diagnostics::CodexUpstreamDiagnostics;
use crate::transport::diagnostics::CodexUpstreamSendPhase;
use super::PreviousResponseUnavailableReason;
use super::pump::WebSocketConnectionObservation;
/// Responses WebSocket 交互错误。
#[derive(Debug, Error)]
@@ -30,12 +31,6 @@ pub enum CodexWebSocketExchangeError {
/// 建连超时时长。
timeout: Duration,
},
/// 普通请求的 WebSocket 快路径预算耗尽,payload 尚未发送。
#[error("websocket fast-path connect budget exhausted after {timeout:?}")]
FastPathTimeout {
/// 快路径等待时长。
timeout: Duration,
},
/// origin WebSocket 冷建连熔断中。
#[error("websocket origin circuit is open")]
OriginCircuitOpen,
@@ -101,6 +96,13 @@ pub enum CodexWebSocketExchangeError {
/// 首个上游事件超时时长。
timeout: Duration,
},
/// 将一个已分类交互错误与物理连接生命周期快照绑定。
#[error("{source}")]
ConnectionObserved {
observation: WebSocketConnectionObservation,
#[source]
source: Box<CodexWebSocketExchangeError>,
},
}
/// 上游在 terminal 事件前发送的 WebSocket close 信息。
@@ -240,7 +242,9 @@ impl CodexWebSocketExchangeError {
)
}
pub(crate) fn close_before_terminal(&self) -> Option<&CodexWebSocketCloseError> {
/// 返回错误链中的上游终态前 Close 帧(若存在)。
#[must_use]
pub fn close_before_terminal(&self) -> Option<&CodexWebSocketCloseError> {
match self {
Self::ClosedBeforeTerminal(close) => Some(close),
Self::PostSendAmbiguous {
@@ -250,7 +254,54 @@ impl CodexWebSocketExchangeError {
| Self::ReusedConnectionDiedBeforeFirstEvent {
source: Some(source),
..
} => source.close_before_terminal(),
}
| Self::ConnectionObserved { source, .. } => source.close_before_terminal(),
_ => None,
}
}
pub(crate) fn with_connection_observation(
self,
observation: WebSocketConnectionObservation,
) -> Self {
match self {
Self::ConnectionObserved { .. } => self,
source => Self::ConnectionObserved {
observation,
source: Box::new(source),
},
}
}
/// 返回错误关联的物理 WebSocket 连接生命周期快照。
#[must_use]
pub fn connection_observation(&self) -> Option<&WebSocketConnectionObservation> {
match self {
Self::ConnectionObserved { observation, .. } => Some(observation),
Self::PostSendAmbiguous {
source: Some(source),
..
}
| Self::ReusedConnectionDiedBeforeFirstEvent {
source: Some(source),
..
} => source.connection_observation(),
_ => None,
}
}
/// 返回去掉观测 wrapper 后的原始分类错误。
pub(crate) fn classified(&self) -> &Self {
match self {
Self::ConnectionObserved { source, .. } => source.classified(),
_ => self,
}
}
#[must_use]
pub fn continuation_unavailable_reason(&self) -> Option<PreviousResponseUnavailableReason> {
match self.classified() {
Self::ContinuationUnavailable { reason } => Some(*reason),
_ => None,
}
}
@@ -277,11 +328,13 @@ impl CodexWebSocketExchangeError {
/// opening 阶段只有明确的 transport 可用性失败才能切到同账号 HTTP。
pub(in crate::transport) fn allows_pre_send_http_fallback(&self) -> bool {
if let Self::ConnectionObserved { source, .. } = self {
return source.allows_pre_send_http_fallback();
}
matches!(
self,
Self::Connect(_)
| Self::ConnectTimeout { .. }
| Self::FastPathTimeout { .. }
| Self::OriginCircuitOpen
| Self::OriginHalfOpenBusy
| Self::SharedConnectFailed
@@ -291,7 +344,7 @@ impl CodexWebSocketExchangeError {
/// 精确连接状态不可用属于本地池路由事实,不消耗上游 WS 重试预算。
pub(in crate::transport) fn requires_immediate_pool_fallback(&self) -> bool {
matches!(self, Self::ContinuationUnavailable { .. })
matches!(self.classified(), Self::ContinuationUnavailable { .. })
}
/// 首个业务事件交付前,普通 WebSocket 失败可切到同账号 HTTP。
@@ -302,6 +355,7 @@ impl CodexWebSocketExchangeError {
source: Some(source),
..
} => source.allows_pre_delivery_http_fallback(),
Self::ConnectionObserved { source, .. } => source.allows_pre_delivery_http_fallback(),
_ => true,
}
}
@@ -31,7 +31,7 @@ pub struct CodexWebSocketStreamingExchange {
pub(crate) websocket_connection_id: Uuid,
/// 由 WebSocket 事件转换出的 live SSE 字节流。
pub body: CodexWebSocketSseStream,
/// 上游 metadata 帧中的最新 turn state。
/// 上游为本次响应返回的首个 turn state。
pub turn_state: Option<String>,
/// 上游握手响应里的 `set-cookie` 列表。
pub set_cookie_headers: Vec<String>,
@@ -39,7 +39,7 @@ pub struct CodexWebSocketStreamingExchange {
pub rate_limit_headers: Vec<(String, String)>,
/// 上游内部 `codex.rate_limits` 事件里的结构化动态更新。
pub rate_limit_updates: CodexWebSocketRateLimitUpdates,
/// 上游内部 metadata 事件里的动态 turn state。
/// 上游内部 metadata 事件里的首个动态 turn state。
pub turn_state_update: CodexWebSocketTurnStateUpdate,
/// WebSocket 连接池决策。
pub pool_decision: Option<WebSocketPoolDecision>,
@@ -63,5 +63,10 @@ pub(super) fn reusable_websocket_metadata(
mut metadata: CodexWebSocketConnectionMetadata,
) -> CodexWebSocketConnectionMetadata {
metadata.rate_limit_headers.clear();
metadata.turn_state = None;
metadata
.response_metadata
.client_headers
.retain(|(name, _)| !name.eq_ignore_ascii_case("x-codex-turn-state"));
metadata
}
@@ -21,7 +21,6 @@ pub(in crate::transport::websocket) enum WebSocketTerminalKind {
pub(super) enum ExchangeAction {
RateLimits(events::ParsedRateLimits),
TurnState(String),
Forward {
frame: String,
terminal: Option<WebSocketTerminalKind>,
@@ -32,6 +31,7 @@ pub(super) enum ExchangeAction {
pub(super) struct ReducedWebSocketEvent {
pub(super) action: ExchangeAction,
pub(super) diagnostic_event_type: Option<String>,
pub(super) turn_state_update: Option<String>,
}
pub(super) fn reduce_websocket_event(
@@ -45,6 +45,7 @@ pub(super) fn reduce_websocket_event(
return Ok(ReducedWebSocketEvent {
action: ExchangeAction::Ignore,
diagnostic_event_type: None,
turn_state_update: None,
});
};
let diagnostic_event_type = diagnostic_event_type(websocket_event_type(&value));
@@ -54,6 +55,7 @@ pub(super) fn reduce_websocket_event(
return Ok(ReducedWebSocketEvent {
action: ExchangeAction::RateLimits(parsed),
diagnostic_event_type,
turn_state_update: None,
});
}
@@ -61,13 +63,13 @@ pub(super) fn reduce_websocket_event(
&mut metadata.response_metadata,
websocket_metadata_headers(&value),
);
if let Some(turn_state) = websocket_metadata_turn_state(&value) {
let turn_state_update = websocket_metadata_turn_state(&value).and_then(|turn_state| {
if metadata.turn_state.is_some() {
return None;
}
metadata.turn_state = Some(turn_state.clone());
return Ok(ReducedWebSocketEvent {
action: ExchangeAction::TurnState(turn_state),
diagnostic_event_type,
});
}
Some(turn_state)
});
let event = websocket_event_type(&value);
if event == Some("response.completed")
@@ -89,6 +91,7 @@ pub(super) fn reduce_websocket_event(
Ok(ReducedWebSocketEvent {
action,
diagnostic_event_type,
turn_state_update,
})
}
@@ -11,7 +11,9 @@ use super::super::{
CodexWebSocketConnectionMetadata, PooledWebSocketConnection, WebSocketContinuationState,
WebSocketPoolLease,
},
pump::{PumpExitReason, PumpedWebSocket},
pump::{
PumpExitReason, PumpedWebSocket, WebSocketConnectionObservation, transport_metric_reason,
},
};
use super::io::{next_websocket_message, receive_idle_timeout, reused_stream_receive_error};
use super::reducer::{ExchangeAction, WebSocketTerminalKind, reduce_websocket_event};
@@ -173,12 +175,22 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
let message = match message {
Ok(message) => message,
Err(error) => {
let error = match error {
CodexWebSocketExchangeError::ReceiveIdleTimeout { timeout }
if !saw_upstream_activity =>
{
CodexWebSocketExchangeError::InitialEventTimeout { timeout }
}
error => error,
};
let observation = connection_observation(&websocket, &error);
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamReceiveFailed,
)
.await;
let error = error.with_connection_observation(observation);
let error = if reused_connection {
reused_stream_receive_error(error)
} else {
@@ -197,6 +209,8 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
text.to_string()
}
tungstenite::Message::Binary(_) => {
let error = CodexWebSocketExchangeError::UnexpectedBinaryEvent;
let observation = connection_observation(&websocket, &error);
discard_stream_websocket(
websocket,
pool_return,
@@ -204,24 +218,26 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
)
.await;
let _ = tx
.send(Err(CodexWebSocketExchangeError::UnexpectedBinaryEvent))
.send(Err(error.with_connection_observation(observation)))
.await;
return;
}
tungstenite::Message::Close(frame) => {
let websocket_connection_id = websocket.connection_id();
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamClosed,
)
.await;
let error = CodexWebSocketExchangeError::closed_before_terminal_on(
websocket_connection_id,
frame.as_ref().map(|frame| u16::from(frame.code)),
frame.map(|frame| frame.reason.to_string()),
last_event_type.clone(),
);
let observation = connection_observation(&websocket, &error);
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamClosed,
)
.await;
let error = error.with_connection_observation(observation);
let error = if reused_connection {
reused_stream_receive_error(error)
} else {
@@ -235,12 +251,19 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
let reduced = match reduce_websocket_event(&raw, &mut metadata, &mut continuation) {
Ok(reduced) => reduced,
Err(error) => {
let observation =
(!matches!(error.classified(), CodexWebSocketExchangeError::Upstream(_)))
.then(|| connection_observation(&websocket, &error));
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamReceiveFailed,
)
.await;
let error = match observation {
Some(observation) => error.with_connection_observation(observation),
None => error,
};
let _ = tx.send(Err(error)).await;
return;
}
@@ -248,15 +271,17 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
if let Some(event_type) = reduced.diagnostic_event_type {
last_event_type = Some(event_type);
}
if let Some(turn_state) = reduced.turn_state_update {
let mut pending = turn_state_update.lock().await;
if pending.is_none() {
*pending = Some(turn_state);
}
}
let (frame, terminal) = match reduced.action {
ExchangeAction::RateLimits(rate_limits) => {
rate_limit_updates.lock().await.push(rate_limits);
continue;
}
ExchangeAction::TurnState(turn_state) => {
*turn_state_update.lock().await = Some(turn_state);
continue;
}
ExchangeAction::Forward { frame, terminal } => (frame, terminal),
ExchangeAction::Ignore => continue,
};
@@ -301,12 +326,6 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
.exit_reason()
.and_then(|reason| reason.upstream_close().cloned())
.map(|close| close.with_connection_id(websocket_connection_id));
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamClosed,
)
.await;
let error = match upstream_close {
Some(close) => CodexWebSocketExchangeError::ClosedBeforeTerminal(
close.with_last_event_type(last_event_type),
@@ -318,6 +337,14 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
last_event_type,
),
};
let observation = connection_observation(&websocket, &error);
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamClosed,
)
.await;
let error = error.with_connection_observation(observation);
let error = if reused_connection {
reused_stream_receive_error(error)
} else {
@@ -353,6 +380,12 @@ async fn discard_stream_websocket(
reason: StreamWebSocketDiscardReason,
) {
let websocket_connection_id = websocket.connection_id();
let observation = websocket.observation();
let tombstone_observation = if observation.exit_reason() == "running" {
observation.clone().with_exit_reason(reason.as_str())
} else {
observation.clone()
};
let pump_exit = websocket.exit_reason();
let pump_exit_reason = pump_exit
.as_ref()
@@ -367,11 +400,45 @@ async fn discard_stream_websocket(
reason = reason.as_str(),
pump_exit_reason,
pump_exit_detail,
connection_exit_reason = observation.exit_reason(),
connection_age_ms = observation.age_ms(),
connection_idle_ms = observation.idle_ms(),
pooled = pool_return.is_some(),
"Discarding Responses WebSocket stream"
);
if let Some(pool_return) = pool_return {
pool_return.lease.discard().await;
pool_return
.lease
.discard_with_observation(tombstone_observation)
.await;
}
websocket.close().await;
}
fn connection_observation(
websocket: &PumpedWebSocket,
error: &CodexWebSocketExchangeError,
) -> WebSocketConnectionObservation {
let observation = websocket.observation();
if observation.exit_reason() != "running" {
return observation;
}
observation.with_exit_reason(exchange_exit_reason(error))
}
fn exchange_exit_reason(error: &CodexWebSocketExchangeError) -> &'static str {
match error.classified() {
CodexWebSocketExchangeError::Transport(error) => transport_metric_reason(error),
CodexWebSocketExchangeError::ClosedBeforeTerminal(close) => {
if close.code() == Some(1000) {
"normal_close"
} else {
"upstream_close"
}
}
CodexWebSocketExchangeError::ReceiveIdleTimeout { .. } => "receive_idle_timeout",
CodexWebSocketExchangeError::InitialEventTimeout { .. } => "initial_event_timeout",
CodexWebSocketExchangeError::UnexpectedBinaryEvent => "unexpected_binary_event",
_ => "exchange_failure",
}
}
@@ -33,10 +33,11 @@ pub use self::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey,
WebSocketPoolBypassReason, WebSocketPoolDecision,
},
pump::WebSocketConnectionObservation,
};
pub(crate) use self::{
coordinator::{
PreparedWebSocket, WEBSOCKET_FAST_PATH_BUDGET,
PreparedWebSocket, WEBSOCKET_FAST_PATH_BUDGET, WebSocketFastPath,
execute_prepared_response_create_request_stream, post_send_ambiguous,
prepare_response_create_request_with_pool,
},
@@ -70,16 +70,23 @@ pub enum PreviousResponseUnavailableReason {
UpstreamRejected,
}
impl fmt::Display for PreviousResponseUnavailableReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
impl PreviousResponseUnavailableReason {
#[must_use]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::PoolUnavailable => "pool_unavailable",
Self::FreshConnectionRequired => "fresh_connection_required",
Self::ConnectionBusy => "connection_busy",
Self::LatestResponseMismatch => "latest_response_mismatch",
Self::ReusedConnectionLost => "reused_connection_lost",
Self::UpstreamRejected => "upstream_rejected",
})
}
}
}
impl fmt::Display for PreviousResponseUnavailableReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
@@ -14,6 +14,7 @@ use uuid::Uuid;
use super::state::{CodexWebSocketPoolKey, PooledWebSocketConnection, WebSocketPoolReservation};
use super::{CodexWebSocketPool, WebSocketPoolBypassReason};
use crate::transport::websocket::pump::WebSocketConnectionObservation;
pub(crate) enum WebSocketPoolAcquire {
Reused {
@@ -22,6 +23,7 @@ pub(crate) enum WebSocketPoolAcquire {
},
Connect(WebSocketPoolConnectLease),
Wait(WebSocketPoolConnectWaiter),
ContinuationLost(WebSocketConnectionObservation),
Bypass(WebSocketPoolBypassReason),
}
@@ -182,10 +184,12 @@ impl WebSocketPoolLease {
self.armed = false;
}
pub(crate) async fn discard(mut self) {
pub(crate) async fn discard_with_observation(
mut self,
observation: WebSocketConnectionObservation,
) {
self.pool
.discard_reserved(&self.key, self.reservation.id)
.await;
.discard_reserved_with_observation(&self.key, self.reservation.id, observation);
self.armed = false;
}
}
@@ -19,6 +19,7 @@ use self::state::{
close_pooled_connections,
};
use super::pump::PumpKeepalive;
use super::pump::WebSocketConnectionObservation;
pub use self::state::CodexWebSocketPoolKey;
pub(crate) use self::{
@@ -154,6 +155,7 @@ impl CodexWebSocketPool {
let mut connections_to_close = Vec::new();
let acquire = {
let mut state = self.lock_state();
let mut continuation_loss = None;
if !self.config.enabled || state.shutting_down {
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Disabled);
}
@@ -163,9 +165,14 @@ impl CodexWebSocketPool {
&& slot.latest_response_id() == Some(response_id))
.then(|| candidate.clone())
}) else {
return WebSocketPoolAcquire::Bypass(
WebSocketPoolBypassReason::ContinuationNotFound,
);
return state
.continuation_loss(key, response_id, tokio::time::Instant::now())
.map_or(
WebSocketPoolAcquire::Bypass(
WebSocketPoolBypassReason::ContinuationNotFound,
),
WebSocketPoolAcquire::ContinuationLost,
);
};
key
} else {
@@ -188,9 +195,9 @@ impl CodexWebSocketPool {
};
// 零成本探活:后台 pump 已实时感知连接死亡(RST/Close/EOF/失活),
// 复用前只需读取 is_closed 标志,避免复用到静默死连接后卡到超时。
if connection.created_at.elapsed() < self.config.max_age
&& !connection.websocket.is_closed()
{
let expired = connection.created_at.elapsed() >= self.config.max_age;
let closed = connection.websocket.is_closed();
if !expired && !closed {
let lease = WebSocketPoolLease::reserve(
self.clone(),
key.clone(),
@@ -202,12 +209,31 @@ impl CodexWebSocketPool {
);
return WebSocketPoolAcquire::Reused { connection, lease };
}
let observation = if expired && !closed {
connection
.websocket
.observation()
.with_exit_reason("max_age_expired")
} else {
connection.websocket.observation()
};
state.remember_continuation_loss(
&key,
connection.continuation.latest_response_id(),
observation.clone(),
tokio::time::Instant::now(),
);
connections_to_close.push(*connection);
if required_response_id.is_some() {
continuation_loss = Some(observation);
}
}
None => {}
}
let acquire = if required_response_id.is_some() {
let acquire = if let Some(observation) = continuation_loss {
WebSocketPoolAcquire::ContinuationLost(observation)
} else if required_response_id.is_some() {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::ContinuationNotFound)
} else {
let connect_permit = self.connect_semaphore.clone().try_acquire_owned().ok();
@@ -259,6 +285,17 @@ impl CodexWebSocketPool {
if reservation.id == reservation_id
);
if owns_reservation && (expired || state.shutting_down || !self.config.enabled) {
if expired && let Some(connection) = connection.as_ref() {
state.remember_continuation_loss(
key,
connection.continuation.latest_response_id(),
connection
.websocket
.observation()
.with_exit_reason("max_age_expired"),
tokio::time::Instant::now(),
);
}
state.slots.remove(key);
} else if owns_reservation && let Some(connection) = connection.take() {
state.slots.insert(
@@ -274,10 +311,6 @@ impl CodexWebSocketPool {
}
}
async fn discard_reserved(&self, key: &CodexWebSocketPoolKey, reservation_id: Uuid) {
self.discard_reserved_now(key, reservation_id);
}
fn discard_reserved_now(&self, key: &CodexWebSocketPoolKey, reservation_id: Uuid) {
let mut state = self.lock_state();
if matches!(
@@ -288,6 +321,28 @@ impl CodexWebSocketPool {
}
}
fn discard_reserved_with_observation(
&self,
key: &CodexWebSocketPoolKey,
reservation_id: Uuid,
observation: WebSocketConnectionObservation,
) {
let mut state = self.lock_state();
let latest_response_id = match state.slots.get(key) {
Some(WebSocketPoolSlot::Busy(reservation)) if reservation.id == reservation_id => {
reservation.latest_response_id.clone()
}
_ => return,
};
state.remember_continuation_loss(
key,
latest_response_id.as_deref(),
observation,
tokio::time::Instant::now(),
);
state.slots.remove(key);
}
async fn finish_connect_reserved(
&self,
key: &CodexWebSocketPoolKey,
@@ -1,6 +1,9 @@
//! WebSocket 连接池状态和值对象。
use std::{collections::HashMap, time::Duration};
use std::{
collections::{HashMap, VecDeque},
time::Duration,
};
use sha2::{Digest, Sha256};
use tokio::{sync::watch, time::Instant};
@@ -10,7 +13,7 @@ use uuid::Uuid;
use super::super::super::{
diagnostics::CodexUpstreamDiagnostics, response_meta::CodexResponseMetadata,
};
use super::super::pump::PumpedWebSocket;
use super::super::pump::{PumpedWebSocket, WebSocketConnectionObservation};
use super::lease::WebSocketPoolConnectOutcome;
/// WebSocket 连接池 key。
@@ -20,6 +23,7 @@ pub struct CodexWebSocketPoolKey {
account_id: String,
conversation_id: String,
connection_profile: String,
downstream_connection_id: String,
}
impl CodexWebSocketPoolKey {
@@ -34,6 +38,7 @@ impl CodexWebSocketPoolKey {
account_id: account_id.into(),
conversation_id: conversation_id.into(),
connection_profile: String::new(),
downstream_connection_id: String::new(),
}
}
@@ -43,6 +48,15 @@ impl CodexWebSocketPoolKey {
self
}
/// 隔离同一逻辑会话中由不同下游 WebSocket 驱动的并发响应链。
pub(crate) fn with_downstream_connection_id(
mut self,
connection_id: impl Into<String>,
) -> Self {
self.downstream_connection_id = connection_id.into();
self
}
pub(crate) fn account_id(&self) -> &str {
&self.account_id
}
@@ -57,6 +71,7 @@ impl CodexWebSocketPoolKey {
self.account_id.as_str(),
self.conversation_id.as_str(),
self.connection_profile.as_str(),
self.downstream_connection_id.as_str(),
])
}
@@ -67,12 +82,72 @@ impl CodexWebSocketPoolKey {
}
}
const CONTINUATION_TOMBSTONE_TTL: Duration = Duration::from_mins(30);
const MAX_CONTINUATION_TOMBSTONES: usize = 4_096;
#[derive(Default)]
pub(super) struct WebSocketPoolState {
pub(super) slots: HashMap<CodexWebSocketPoolKey, WebSocketPoolSlot>,
continuation_tombstones: VecDeque<WebSocketContinuationTombstone>,
pub(super) shutting_down: bool,
}
struct WebSocketContinuationTombstone {
key: CodexWebSocketPoolKey,
response_id: String,
observation: WebSocketConnectionObservation,
expires_at: Instant,
}
impl WebSocketPoolState {
pub(super) fn remember_continuation_loss(
&mut self,
key: &CodexWebSocketPoolKey,
response_id: Option<&str>,
observation: WebSocketConnectionObservation,
now: Instant,
) {
let Some(response_id) = response_id else {
return;
};
self.prune_continuation_tombstones(now);
self.continuation_tombstones.retain(|tombstone| {
!(tombstone.key.same_logical_connection(key) && tombstone.response_id == response_id)
});
while self.continuation_tombstones.len() >= MAX_CONTINUATION_TOMBSTONES {
self.continuation_tombstones.pop_front();
}
self.continuation_tombstones
.push_back(WebSocketContinuationTombstone {
key: key.clone(),
response_id: response_id.to_owned(),
observation,
expires_at: now + CONTINUATION_TOMBSTONE_TTL,
});
}
pub(super) fn continuation_loss(
&mut self,
key: &CodexWebSocketPoolKey,
response_id: &str,
now: Instant,
) -> Option<WebSocketConnectionObservation> {
self.prune_continuation_tombstones(now);
self.continuation_tombstones
.iter()
.rev()
.find(|tombstone| {
tombstone.key.same_logical_connection(key) && tombstone.response_id == response_id
})
.map(|tombstone| tombstone.observation.clone())
}
fn prune_continuation_tombstones(&mut self, now: Instant) {
self.continuation_tombstones
.retain(|tombstone| tombstone.expires_at > now);
}
}
#[derive(Clone)]
pub(crate) struct CodexWebSocketConnectionMetadata {
pub(crate) turn_state: Option<String>,
@@ -174,7 +174,23 @@ impl CodexWebSocketPool {
.collect::<Vec<_>>();
for key in keys {
match state.slots.remove(&key) {
Some(WebSocketPoolSlot::Idle { connection, .. }) => close.push(*connection),
Some(WebSocketPoolSlot::Idle { connection, .. }) => {
let observation = if connection.websocket.is_closed() {
connection.websocket.observation()
} else {
connection
.websocket
.observation()
.with_exit_reason("max_age_expired")
};
state.remember_continuation_loss(
&key,
connection.continuation.latest_response_id(),
observation,
now,
);
close.push(*connection);
}
Some(WebSocketPoolSlot::Busy(reservation)) => {
tracing::warn!(
account_id = key.account_id(),
@@ -89,6 +89,7 @@ pub(crate) enum PumpExitReason {
LocalClose,
OutboundTransportError {
message: String,
metric_reason: &'static str,
},
UpstreamCloseFrame {
close: Option<CodexWebSocketCloseError>,
@@ -96,6 +97,7 @@ pub(crate) enum PumpExitReason {
UpstreamEof,
InboundTransportError {
message: String,
metric_reason: &'static str,
},
PongTimeout {
timeout: Duration,
@@ -105,6 +107,7 @@ pub(crate) enum PumpExitReason {
},
KeepaliveTransportError {
message: String,
metric_reason: &'static str,
},
MessageReceiverClosed,
}
@@ -127,9 +130,9 @@ impl PumpExitReason {
pub(crate) fn detail(&self) -> Option<String> {
match self {
Self::OutboundTransportError { message }
| Self::InboundTransportError { message }
| Self::KeepaliveTransportError { message } => Some(message.clone()),
Self::OutboundTransportError { message, .. }
| Self::InboundTransportError { message, .. }
| Self::KeepaliveTransportError { message, .. } => Some(message.clone()),
Self::UpstreamCloseFrame { close } => close.as_ref().map(ToString::to_string),
Self::PongTimeout { timeout } | Self::LivenessTimeout { timeout } => {
Some(format!("{timeout:?}"))
@@ -141,6 +144,27 @@ impl PumpExitReason {
}
}
pub(crate) fn metric_reason(&self) -> &'static str {
match self {
Self::CommandChannelClosed => "command_channel_closed",
Self::LocalClose => "local_close",
Self::OutboundTransportError { metric_reason, .. }
| Self::InboundTransportError { metric_reason, .. }
| Self::KeepaliveTransportError { metric_reason, .. } => metric_reason,
Self::UpstreamCloseFrame { close } => {
if close.as_ref().and_then(CodexWebSocketCloseError::code) == Some(1000) {
"normal_close"
} else {
"upstream_close"
}
}
Self::UpstreamEof => "eof",
Self::PongTimeout { .. } => "pong_timeout",
Self::LivenessTimeout { .. } => "liveness_timeout",
Self::MessageReceiverClosed => "message_receiver_closed",
}
}
pub(crate) fn upstream_close(&self) -> Option<&CodexWebSocketCloseError> {
match self {
Self::UpstreamCloseFrame { close: Some(close) } => Some(close),
@@ -183,13 +207,71 @@ struct PendingPing {
deadline: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebSocketConnectionObservation {
connection_id: Uuid,
exit_reason: String,
age_ms: u64,
idle_ms: u64,
}
impl WebSocketConnectionObservation {
pub(crate) fn with_exit_reason(mut self, exit_reason: &'static str) -> Self {
self.exit_reason = exit_reason.to_owned();
self
}
pub const fn connection_id(&self) -> Uuid {
self.connection_id
}
pub fn exit_reason(&self) -> &str {
&self.exit_reason
}
pub const fn age_ms(&self) -> u64 {
self.age_ms
}
pub const fn idle_ms(&self) -> u64 {
self.idle_ms
}
}
struct PumpLifecycleState {
opened_at: Instant,
last_activity: Instant,
closed_at: Option<Instant>,
exit_reason: Option<PumpExitReason>,
}
impl PumpLifecycleState {
fn observation(
&self,
connection_id: Uuid,
observed_at: Instant,
) -> WebSocketConnectionObservation {
let effective_end = self.closed_at.unwrap_or(observed_at);
WebSocketConnectionObservation {
connection_id,
exit_reason: self
.exit_reason
.as_ref()
.map_or("running", PumpExitReason::metric_reason)
.to_owned(),
age_ms: duration_millis(effective_end.duration_since(self.opened_at)),
idle_ms: duration_millis(effective_end.duration_since(self.last_activity)),
}
}
}
/// 由后台 pump 任务托管的 WebSocket 连接句柄。
pub(crate) struct PumpedWebSocket {
connection_id: Uuid,
tx_command: mpsc::Sender<PumpCommand>,
rx_message: mpsc::Receiver<Result<Message, tungstenite::Error>>,
closed: Arc<AtomicBool>,
exit_reason: Arc<Mutex<Option<PumpExitReason>>>,
lifecycle: Arc<Mutex<PumpLifecycleState>>,
pump: Option<JoinHandle<()>>,
}
@@ -212,10 +294,16 @@ impl PumpedWebSocket {
let (tx_command, rx_command) = mpsc::channel::<PumpCommand>(PUMP_COMMAND_BUFFER);
let (tx_message, rx_message) = mpsc::channel(PUMP_MESSAGE_BUFFER);
let connection_id = Uuid::new_v4();
let opened_at = Instant::now();
let closed = Arc::new(AtomicBool::new(false));
let closed_for_task = Arc::clone(&closed);
let exit_reason = Arc::new(Mutex::new(None));
let exit_reason_for_task = Arc::clone(&exit_reason);
let lifecycle = Arc::new(Mutex::new(PumpLifecycleState {
opened_at,
last_activity: opened_at,
closed_at: None,
exit_reason: None,
}));
let lifecycle_for_task = Arc::clone(&lifecycle);
let pump = tokio::spawn(async move {
pump_loop(
connection_id,
@@ -225,7 +313,7 @@ impl PumpedWebSocket {
keepalive,
context,
closed_for_task,
exit_reason_for_task,
lifecycle_for_task,
)
.await;
});
@@ -234,7 +322,7 @@ impl PumpedWebSocket {
tx_command,
rx_message,
closed,
exit_reason,
lifecycle,
pump: Some(pump),
}
}
@@ -272,12 +360,20 @@ impl PumpedWebSocket {
}
pub(crate) fn exit_reason(&self) -> Option<PumpExitReason> {
self.exit_reason
self.lifecycle
.lock()
.expect("WebSocket pump exit reason lock poisoned")
.expect("WebSocket pump lifecycle lock poisoned")
.exit_reason
.clone()
}
pub(crate) fn observation(&self) -> WebSocketConnectionObservation {
self.lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned")
.observation(self.connection_id, Instant::now())
}
/// 主动关闭连接(best-effort 发送 Close 帧)。
pub(crate) async fn close(&self) {
if !self.is_closed() {
@@ -306,9 +402,12 @@ async fn pump_loop(
keepalive: PumpKeepalive,
context: PumpLogContext,
closed: Arc<AtomicBool>,
exit_reason: Arc<Mutex<Option<PumpExitReason>>>,
lifecycle: Arc<Mutex<PumpLifecycleState>>,
) {
let mut last_activity = Instant::now();
let mut last_activity = lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned")
.last_activity;
let ping_interval = keepalive.ping_interval.filter(|d| !d.is_zero());
let ping_timeout = keepalive.ping_timeout.filter(|d| !d.is_zero());
let liveness_timeout = keepalive.liveness_timeout.filter(|d| !d.is_zero());
@@ -341,6 +440,7 @@ async fn pump_loop(
}
// 背压期间无法读取 socket,不能把这段本地下游阻塞误算成上游静默。
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
}
command = rx_command.recv() => {
let Some(command) = command else {
@@ -370,12 +470,14 @@ async fn pump_loop(
None => break 'pump PumpExitReason::UpstreamEof,
Some(Ok(message)) => {
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
observe_inbound(connection_id, &mut pending_ping, &message);
match message {
Message::Ping(payload) => {
if let Err(error) = inner.send(Message::Pong(payload)).await {
break 'pump PumpExitReason::KeepaliveTransportError {
message: error.to_string(),
metric_reason: transport_metric_reason(&error),
};
}
}
@@ -410,6 +512,7 @@ async fn pump_loop(
Some(Err(err)) => {
let terminal_reason = PumpExitReason::InboundTransportError {
message: err.to_string(),
metric_reason: transport_metric_reason(&err),
};
if let Some(reason) = enqueue_inbound(
&tx_message,
@@ -452,10 +555,22 @@ async fn pump_loop(
};
closed.store(true, Ordering::Release);
*exit_reason
.lock()
.expect("WebSocket pump exit reason lock poisoned") = Some(reason.clone());
log_pump_exit(connection_id, &context, &reason, backpressure_events);
let observation = {
let closed_at = Instant::now();
let mut lifecycle = lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned");
lifecycle.closed_at = Some(closed_at);
lifecycle.exit_reason = Some(reason.clone());
lifecycle.observation(connection_id, closed_at)
};
log_pump_exit(
connection_id,
&context,
&reason,
&observation,
backpressure_events,
);
if reason.should_send_close() {
let _ = inner.send(Message::Close(None)).await;
@@ -492,10 +607,16 @@ async fn handle_command(inner: &mut RawWsStream, command: PumpCommand) -> Option
let PumpCommand::Send { message, ack } = command;
let is_close = matches!(message, Message::Close(_));
let result = inner.send(message).await;
let transport_error = result.as_ref().err().map(ToString::to_string);
let transport_error = result
.as_ref()
.err()
.map(|error| (error.to_string(), transport_metric_reason(error)));
let _ = ack.send(result);
match transport_error {
Some(message) => Some(PumpExitReason::OutboundTransportError { message }),
Some((message, metric_reason)) => Some(PumpExitReason::OutboundTransportError {
message,
metric_reason,
}),
None if is_close => Some(PumpExitReason::LocalClose),
None => None,
}
@@ -517,6 +638,34 @@ fn observe_inbound(connection_id: Uuid, pending_ping: &mut Option<PendingPing>,
*pending_ping = None;
}
fn record_activity(lifecycle: &Mutex<PumpLifecycleState>, observed_at: Instant) {
lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned")
.last_activity = observed_at;
}
pub(crate) fn transport_metric_reason(error: &tungstenite::Error) -> &'static str {
match error {
tungstenite::Error::Io(error) => match error.kind() {
std::io::ErrorKind::ConnectionReset => "tcp_reset",
std::io::ErrorKind::ConnectionAborted => "connection_aborted",
std::io::ErrorKind::BrokenPipe => "broken_pipe",
std::io::ErrorKind::UnexpectedEof => "unexpected_eof",
std::io::ErrorKind::TimedOut => "transport_timeout",
_ => "transport_error",
},
tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
) => "tcp_reset",
_ => "transport_error",
}
}
fn duration_millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
async fn send_keepalive_ping(
inner: &mut RawWsStream,
sequence: &mut u64,
@@ -529,6 +678,7 @@ async fn send_keepalive_ping(
.await
.map_err(|error| PumpExitReason::KeepaliveTransportError {
message: error.to_string(),
metric_reason: transport_metric_reason(&error),
})?;
Ok(ping_timeout.map(|timeout| PendingPing {
payload,
@@ -540,6 +690,7 @@ fn log_pump_exit(
connection_id: Uuid,
context: &PumpLogContext,
reason: &PumpExitReason,
observation: &WebSocketConnectionObservation,
backpressure_events: u64,
) {
let detail = reason.detail().unwrap_or_default();
@@ -551,6 +702,9 @@ fn log_pump_exit(
account_id = %account_id,
conversation_id_hash = %conversation_id_hash,
pump_exit_reason = reason.as_str(),
connection_exit_reason = observation.exit_reason(),
connection_age_ms = observation.age_ms(),
connection_idle_ms = observation.idle_ms(),
pump_exit_detail = detail,
backpressure_events,
"Responses WebSocket pump stopped unexpectedly"
@@ -565,6 +719,9 @@ fn log_pump_exit(
account_id = %account_id,
conversation_id_hash = %conversation_id_hash,
pump_exit_reason = reason.as_str(),
connection_exit_reason = observation.exit_reason(),
connection_age_ms = observation.age_ms(),
connection_idle_ms = observation.idle_ms(),
pump_exit_detail = detail,
upstream_close_code = ?upstream_close.and_then(CodexWebSocketCloseError::code),
upstream_error_raw,
@@ -578,6 +735,9 @@ fn log_pump_exit(
account_id = %account_id,
conversation_id_hash = %conversation_id_hash,
pump_exit_reason = reason.as_str(),
connection_exit_reason = observation.exit_reason(),
connection_age_ms = observation.age_ms(),
connection_idle_ms = observation.idle_ms(),
pump_exit_detail = detail,
backpressure_events,
"Responses WebSocket pump stopped"
@@ -336,7 +336,10 @@ async fn openai_core_provider_projects_codex_request_observation_without_routing
)]));
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
let observation = bundle.core_provider().request_observation(&operation);
let client_key_id = ClientApiKeyId::new("key_openai_admin_observation").expect("client key");
let observation = bundle
.core_provider()
.request_observation(&operation, &client_key_id);
assert_eq!(observation.request_kind.as_deref(), Some("compaction"));
assert_eq!(observation.subagent_kind.as_deref(), Some("review"));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,200 @@
use std::sync::Arc;
use futures::{StreamExt, stream};
use gateway_core::account::{AccountFeedbackStats, ProviderAccountId};
use gateway_core::engine::provider::{EventStream, ProviderCallMetadata, ProviderStream};
use gateway_core::error::{
ClientVisibleUpstreamError, OpaqueUpstreamValue, ProviderError, ProviderErrorKind,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::upstream::{UpstreamSendState, UpstreamTransport};
use provider_openai::openai_failure_affects_account_score;
fn sent_error(kind: ProviderErrorKind, code: Option<&str>) -> ProviderError {
let error = ProviderError::new(kind, UpstreamSendState::Sent);
match code {
Some(code) => error.with_upstream_code(OpaqueUpstreamValue::new(code)),
None => error,
}
}
fn deliver_error_with_openai_feedback(
feedback: &Arc<AccountFeedbackStats>,
provider: &ProviderKind,
account: &ProviderAccountId,
error: ProviderError,
) {
let metadata = ProviderCallMetadata::new(
provider.clone(),
UpstreamModelId::new("gpt-5").expect("model"),
account.clone(),
UpstreamTransport::new("http_sse").expect("transport"),
);
let events: EventStream = Box::pin(stream::iter([Err(error)]));
let mut provider_stream = ProviderStream::new(metadata, events, ())
.with_filtered_account_feedback(Arc::clone(feedback), openai_failure_affects_account_score);
futures::executor::block_on(async {
assert!(
provider_stream
.next()
.await
.is_some_and(|event| event.is_err())
);
});
}
#[test]
fn account_score_failure_filter_should_include_server_overload_as_a_regular_reason() {
let error = sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded"));
assert!(openai_failure_affects_account_score(&error));
}
#[test]
fn account_score_failure_filter_should_accept_the_closed_reason_list() {
for code in [
"server_is_overloaded",
"slow_down",
"rate_limit_exceeded",
"rate_limit_error",
"server_error",
"service_unavailable_error",
] {
let error = sent_error(ProviderErrorKind::Unavailable, Some(code));
assert!(
openai_failure_affects_account_score(&error),
"allowlisted code was rejected: {code}"
);
}
}
#[test]
fn account_score_failure_filter_should_normalize_case_and_whitespace() {
let error = sent_error(ProviderErrorKind::Unavailable, Some(" SERVER_ERROR "));
assert!(openai_failure_affects_account_score(&error));
}
#[test]
fn account_score_failure_filter_should_use_a_structured_type_when_code_is_absent() {
let error = ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"upstream unavailable",
None,
Some("SERVICE_UNAVAILABLE_ERROR".to_owned()),
));
assert!(openai_failure_affects_account_score(&error));
}
#[test]
fn account_score_failure_filter_should_prefer_a_structured_code_over_type() {
let error = ProviderError::new(ProviderErrorKind::InvalidRequest, UpstreamSendState::Sent)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"bad request",
Some("invalid_request".to_owned()),
Some("server_error".to_owned()),
));
assert!(!openai_failure_affects_account_score(&error));
}
#[test]
fn account_score_failure_filter_should_reject_client_and_unknown_failures() {
for error in [
sent_error(ProviderErrorKind::InvalidRequest, Some("invalid_request")),
sent_error(
ProviderErrorKind::ContinuationRecoveryRequired,
Some("previous_response_not_found"),
),
sent_error(ProviderErrorKind::Unsupported, Some("unsupported")),
sent_error(ProviderErrorKind::Unauthorized, Some("token_expired")),
sent_error(ProviderErrorKind::QuotaExhausted, Some("quota_exhausted")),
sent_error(ProviderErrorKind::RateLimited, Some("rate_limit_reached")),
sent_error(
ProviderErrorKind::Unavailable,
Some("internal_server_error"),
),
sent_error(ProviderErrorKind::Unavailable, Some("service_unavailable")),
sent_error(
ProviderErrorKind::Transport,
Some("upstream_transport_error"),
),
sent_error(ProviderErrorKind::Timeout, Some("first_output_timeout")),
sent_error(
ProviderErrorKind::Protocol,
Some("upstream_stream_truncated"),
),
sent_error(
ProviderErrorKind::Unavailable,
Some("upstream_empty_response"),
),
sent_error(ProviderErrorKind::Unavailable, Some("new_unknown_reason")),
sent_error(ProviderErrorKind::Transport, Some("new_unknown_reason")),
sent_error(ProviderErrorKind::Transport, Some("")),
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
.with_status(503),
] {
assert!(
!openai_failure_affects_account_score(&error),
"non-allowlisted failure affected the account score"
);
}
}
#[test]
fn account_score_failure_filter_should_reject_internal_kinds_without_a_reason() {
for kind in [
ProviderErrorKind::Transport,
ProviderErrorKind::Timeout,
ProviderErrorKind::Protocol,
] {
let error = sent_error(kind, None);
assert!(
!openai_failure_affects_account_score(&error),
"unlisted internal failure affected the score: {}",
kind.as_str()
);
}
}
#[test]
fn openai_feedback_should_not_amplify_repeated_client_configuration_errors() {
let feedback = Arc::new(AccountFeedbackStats::default());
let provider = ProviderKind::new("openai").expect("provider");
let account = ProviderAccountId::new("acct_usable_account").expect("account");
for _ in 0..100 {
deliver_error_with_openai_feedback(
&feedback,
&provider,
&account,
sent_error(ProviderErrorKind::InvalidRequest, Some("invalid_request")),
);
}
assert_eq!(
feedback.scheduling_signals(&provider, &account),
(None, None)
);
}
#[test]
fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
let feedback = Arc::new(AccountFeedbackStats::default());
let provider = ProviderKind::new("openai").expect("provider");
let account = ProviderAccountId::new("acct_overloaded").expect("account");
deliver_error_with_openai_feedback(
&feedback,
&provider,
&account,
sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded")),
);
assert_eq!(
feedback.scheduling_signals(&provider, &account).0,
Some(2_000)
);
}
@@ -1 +1,2 @@
mod contract;
mod failure;
@@ -837,6 +837,40 @@ async fn missing_exact_socket_should_fail_without_opening_a_connection() {
);
}
#[tokio::test]
async fn connection_local_continuation_without_a_pool_should_not_open_http_or_websocket() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
);
let mut request = new_chain_request("conversation-exact-no-pool");
request.set_previous_response_id(Some("resp_no_pool".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
let error = backend
.create_response(
&request,
request_context("req_exact_no_pool", Some("chatgpt-account")),
)
.await
.expect_err("connection-local continuation requires its owning pool");
std::assert_matches!(
error,
CodexClientError::WebSocket(CodexWebSocketExchangeError::ContinuationUnavailable {
reason: PreviousResponseUnavailableReason::PoolUnavailable
})
);
assert!(
timeout(Duration::from_millis(50), listener.accept())
.await
.is_err()
);
}
#[tokio::test]
async fn connection_local_continuation_should_fail_after_its_live_socket_disappears() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -852,7 +886,7 @@ async fn connection_local_continuation_should_fail_after_its_live_socket_disappe
))
.await
.unwrap();
websocket.close(None).await.unwrap();
drop(websocket);
closed_tx.send(()).unwrap();
});
let backend = CodexBackendClient::new(
@@ -883,12 +917,18 @@ async fn connection_local_continuation_should_fail_after_its_live_socket_disappe
)
.await
.expect_err("a disappeared live socket cannot be reconstructed from the database");
std::assert_matches!(
error,
CodexClientError::WebSocket(CodexWebSocketExchangeError::ContinuationUnavailable {
reason: PreviousResponseUnavailableReason::FreshConnectionRequired
})
let CodexClientError::WebSocket(error) = error else {
panic!("disappeared connection should remain a typed WebSocket error");
};
assert_eq!(
error.continuation_unavailable_reason(),
Some(PreviousResponseUnavailableReason::ReusedConnectionLost)
);
let observation = error
.connection_observation()
.expect("disappeared connection should retain its lifecycle observation");
assert_eq!(observation.exit_reason(), "tcp_reset");
assert!(observation.age_ms() >= observation.idle_ms());
}
#[tokio::test]
@@ -1114,9 +1154,9 @@ async fn websocket_failure_after_first_delivery_should_not_open_http_fallback()
else {
panic!("expected typed post-delivery WebSocket failure");
};
let CodexWebSocketExchangeError::ClosedBeforeTerminal(close) = *source else {
panic!("expected upstream close frame source");
};
let close = source
.close_before_terminal()
.expect("expected upstream close frame source");
assert!(
close.connection_id().is_some(),
"close failures must retain their WebSocket connection correlation"
@@ -273,6 +273,16 @@ fn transport_should_prefer_websocket_when_requested_without_history() {
assert!(transport_requirement(&request).allows_pre_send_http_fallback());
}
#[test]
fn unspecified_transport_without_history_should_still_be_a_new_chain() {
let request = codex_request("gpt-test", "be brief", Vec::new());
assert_eq!(
transport_requirement(&request),
TransportRequirement::NewChain
);
}
#[test]
fn new_chain_should_allow_pre_delivery_http_fallback() {
let mut request = codex_request("gpt-test", "be brief", Vec::new());
@@ -284,6 +294,25 @@ fn new_chain_should_allow_pre_delivery_http_fallback() {
);
}
#[test]
fn session_transport_recovery_should_only_apply_to_new_chains() {
assert!(TransportRequirement::NewChain.allows_session_transport_recovery());
for requirement in [
TransportRequirement::HttpRequired,
TransportRequirement::ExplicitWebSocketWarmup,
TransportRequirement::ExactWebSocketContinuation,
TransportRequirement::PersistedContinuation,
TransportRequirement::ExternalUnknown,
] {
assert!(
!requirement.allows_session_transport_recovery(),
"session recovery must not override {}",
requirement.as_str()
);
}
}
#[test]
fn transport_should_mark_unknown_previous_response_as_external() {
let mut request = codex_request("gpt-test", "be brief", Vec::new());
@@ -155,6 +155,7 @@ fn websocket_event_to_sse_should_forward_public_events_and_strip_internal_events
);
assert!(websocket_event_to_sse_frame(r#"{"type":"codex.rate_limits"}"#).is_none());
assert!(websocket_event_to_sse_frame(r#"{"type":"response.metadata"}"#).is_none());
assert!(websocket_event_to_sse_frame(r#"{"type":"codex.response.metadata"}"#).is_some());
assert!(websocket_event_to_sse_frame(r#"{"response":{}}"#).is_none());
assert!(websocket_event_to_sse_frame("not-json").is_none());
}
@@ -178,15 +179,48 @@ fn websocket_event_to_sse_should_add_missing_previous_response_recovery_code() {
#[test]
fn websocket_metadata_turn_state_should_accept_case_insensitive_header() {
let event = json!({
"type": "response.metadata",
"headers": {"X-Codex-Turn-State": ["turn-from-metadata"]}
});
for (event, expected) in [
(
json!({
"type": "response.metadata",
"headers": {"X-Codex-Turn-State": ["turn-from-legacy-metadata"]}
}),
"turn-from-legacy-metadata",
),
(
json!({
"type": "codex.response.metadata",
"headers": {"X-Codex-Turn-State": "turn-from-codex-metadata"}
}),
"turn-from-codex-metadata",
),
] {
assert_eq!(
websocket_metadata_turn_state(&event).as_deref(),
Some(expected)
);
}
}
assert_eq!(
websocket_metadata_turn_state(&event).as_deref(),
Some("turn-from-metadata")
);
#[test]
fn websocket_metadata_turn_state_should_ignore_unverified_shapes() {
for event in [
json!({
"type": "codex.response.metadata",
"headers": {"x-codex-turn-state": {"future": "shape"}}
}),
json!({
"type": "codex.response.metadata",
"metadata": {"turn_state": "nested-value"},
"turn_state": "top-level-value"
}),
json!({
"type": "future.response.metadata",
"headers": {"x-codex-turn-state": "unverified-event"}
}),
] {
assert_eq!(websocket_metadata_turn_state(&event), None);
}
}
#[test]
@@ -227,6 +227,26 @@ fn encoder_should_ignore_legacy_context_aliases_and_metadata_fallbacks() {
);
}
#[test]
fn encoder_should_extract_official_websocket_context_projection() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("prompt")),
(
"client_metadata".to_owned(),
json!({
"x-codex-turn-state": "metadata-turn-state",
"turn_id": "metadata-turn-id"
}),
),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(encoded.turn_state.as_deref(), Some("metadata-turn-state"));
assert_eq!(encoded.client_turn_id.as_deref(), Some("metadata-turn-id"));
}
#[test]
fn header_context_should_win_over_body_topline_aliases() {
let payload = ProtocolPayload::json_object(
@@ -236,6 +256,13 @@ fn header_context_should_win_over_body_topline_aliases() {
("input".to_owned(), json!("prompt")),
("turnState".to_owned(), json!("body-turn-state")),
("turnMetadata".to_owned(), json!("body-turn-metadata")),
(
"client_metadata".to_owned(),
json!({
"x-codex-turn-state": "metadata-turn-state",
"turn_id": "metadata-turn-id"
}),
),
]),
)
.expect("OpenAI payload")
@@ -248,6 +275,10 @@ fn header_context_should_win_over_body_topline_aliases() {
"turn_metadata".to_owned(),
Value::String("header-turn-metadata".to_owned()),
),
(
"turn_id".to_owned(),
Value::String("header-turn-id".to_owned()),
),
]));
let request = GenerateRequest::from_protocol_payload(payload);
@@ -258,6 +289,36 @@ fn header_context_should_win_over_body_topline_aliases() {
encoded.turn_metadata.as_deref(),
Some("header-turn-metadata")
);
assert_eq!(encoded.client_turn_id.as_deref(), Some("header-turn-id"));
}
#[test]
fn encoder_should_preserve_downstream_websocket_connection_identity_outside_wire_body() {
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("prompt")),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([(
"downstream_websocket_connection_id".to_owned(),
Value::String("ws_downstream_a".to_owned()),
)]));
let request = GenerateRequest::from_protocol_payload(payload);
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
encoded.downstream_websocket_connection_id.as_deref(),
Some("ws_downstream_a")
);
assert!(
!encoded
.body()
.contains_key("downstream_websocket_connection_id")
);
}
#[test]
@@ -647,7 +647,7 @@ async fn websocket_execute_response_create_request_should_reject_binary_event()
.send(Message::Binary(b"unexpected-binary".to_vec().into()))
.await
.unwrap();
websocket.close(None).await.unwrap();
let _client_close = websocket.next().await;
});
let mut request = codex_request("gpt-test", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_binary_previous".to_owned()));
@@ -664,15 +664,30 @@ async fn websocket_execute_response_create_request_should_reject_binary_event()
.expect_err("binary websocket events should be rejected");
server.await.unwrap();
std::assert_matches!(
error,
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
message,
source: Some(source),
})
if message.contains("unexpected binary websocket event")
&& matches!(*source, CodexWebSocketExchangeError::UnexpectedBinaryEvent)
let CodexClientError::WebSocket(error) = error else {
panic!("binary websocket event should remain a typed WebSocket error");
};
let CodexWebSocketExchangeError::PostSendAmbiguous {
message,
source: Some(source),
} = error
else {
panic!("binary websocket event should remain post-send ambiguous");
};
assert!(message.contains("unexpected binary websocket event"));
assert_eq!(
source
.connection_observation()
.map(|observation| observation.exit_reason()),
Some("unexpected_binary_event")
);
let CodexWebSocketExchangeError::ConnectionObserved { source, .. } = *source else {
panic!("binary websocket event should carry its connection observation");
};
assert!(matches!(
*source,
CodexWebSocketExchangeError::UnexpectedBinaryEvent
));
}
#[tokio::test]
@@ -899,6 +914,191 @@ async fn websocket_execute_response_create_request_should_capture_internal_metad
);
}
#[tokio::test]
async fn websocket_codex_metadata_should_capture_first_turn_state_without_changing_forwarding() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "codex.response.metadata",
"headers": {"x-codex-turn-state": "turn-first"}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.metadata",
"headers": {"x-codex-turn-state": ["turn-second"]}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket
.send(Message::Text(
completed_websocket_response("resp_metadata_alias", 1, 1).into(),
))
.await
.unwrap();
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let response = execute_response_create_request(&prepared)
.await
.expect("metadata aliases should preserve a completed response");
server.await.unwrap();
assert_eq!(response.turn_state.as_deref(), Some("turn-first"));
assert!(response.body.contains("event: codex.response.metadata"));
assert!(!response.body.contains("event: response.metadata\n"));
assert!(response.body.contains("event: response.completed"));
}
#[tokio::test]
async fn reused_websocket_should_not_leak_turn_state_into_the_next_response() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _first = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "codex.response.metadata",
"headers": {"x-codex-turn-state": "turn-from-first-response"}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket
.send(Message::Text(
completed_websocket_response("resp_turn_state_first", 1, 1).into(),
))
.await
.unwrap();
let _second = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
completed_websocket_response("resp_turn_state_second", 1, 1).into(),
))
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(pool);
let request = pooled_websocket_request("conversation-turn-state-reset");
let first = backend
.create_response(
&request,
request_context("req_turn_state_first", Some("chatgpt-account")),
)
.await
.expect("first response should complete");
let second = backend
.create_response(
&request,
request_context("req_turn_state_second", Some("chatgpt-account")),
)
.await
.expect("second response should reuse the WebSocket");
server.await.unwrap();
assert_eq!(
first.turn_state.as_deref(),
Some("turn-from-first-response")
);
assert!(
second
.websocket_pool_decision
.is_some_and(WebSocketPoolDecision::is_reuse)
);
assert_eq!(second.turn_state, None);
assert!(
second
.response_metadata
.client_headers
.iter()
.all(|(name, _)| { !name.eq_ignore_ascii_case("x-codex-turn-state") })
);
}
#[tokio::test]
async fn websocket_request_should_project_trusted_turn_state_and_remove_stale_metadata() {
for (case, turn_state) in [("trusted", Some("trusted-turn-state")), ("cleared", None)] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let message = websocket.next().await.unwrap().unwrap();
let payload = serde_json::from_str::<Value>(&message.into_text().unwrap())
.expect("response.create payload");
websocket
.send(Message::Text(
completed_websocket_response("resp_turn_state_projection", 1, 1).into(),
))
.await
.unwrap();
payload
});
let mut body = codex_request_body("gpt-5.5", "be brief", Vec::new());
body.insert("generate".to_owned(), json!(false));
body.insert("store".to_owned(), json!(false));
let mut request = CodexResponsesRequest::from_body(body);
request.turn_state = turn_state.map(str::to_owned);
request.local_conversation_id = Some(format!("turn-state-projection-{case}"));
request.set_client_metadata(Some(json!({
"x-codex-turn-state": "stale-client-value",
"keep": "preserved"
})));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let mut context = request_context(case, Some("chatgpt-account"));
context.turn_state = turn_state;
let response = backend
.create_response(&request, context)
.await
.unwrap_or_else(|error| panic!("required WebSocket request should complete: {error}"));
let payload = server.await.unwrap();
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
assert_eq!(
payload.pointer("/client_metadata/keep"),
Some(&json!("preserved"))
);
assert_eq!(
payload.pointer("/client_metadata/x-codex-turn-state"),
turn_state.map(|turn_state| json!(turn_state)).as_ref(),
"case {case}"
);
}
}
#[tokio::test]
async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1560,9 +1760,9 @@ async fn codex_backend_client_stream_should_error_when_websocket_closes_before_t
else {
panic!("close-before-terminal should preserve its typed source: {error:?}");
};
let CodexWebSocketExchangeError::ClosedBeforeTerminal(close) = source.as_ref() else {
panic!("post-send ambiguity should wrap a close-before-terminal error: {source:?}");
};
let close = source
.close_before_terminal()
.unwrap_or_else(|| panic!("post-send ambiguity should retain its close: {source:?}"));
assert_eq!(close.last_event_type(), Some("response.output_text.delta"));
std::assert_matches!(
@@ -1,4 +1,5 @@
use super::*;
use provider_openai::transport::websocket::PreviousResponseUnavailableReason;
#[tokio::test]
async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and_conversation() {
@@ -70,6 +71,138 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
assert_eq!(accepted_connections.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn websocket_pool_should_isolate_concurrent_downstream_lanes_for_one_conversation() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
let accepted_connections_for_server = Arc::clone(&accepted_connections);
let (first_accepted_tx, first_accepted_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let mut first = accept_codex_test_websocket(first_stream).await;
first_accepted_tx.send(()).unwrap();
let (second_stream, _) = timeout(Duration::from_secs(2), listener.accept())
.await
.expect("the second downstream lane should open its own upstream websocket")
.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let mut second = accept_codex_test_websocket(second_stream).await;
let (first_warmup, second_warmup) = tokio::join!(first.next(), second.next());
first_warmup.unwrap().unwrap();
second_warmup.unwrap().unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_lane_a", 1, 0).into(),
))
.await
.unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_lane_b", 1, 0).into(),
))
.await
.unwrap();
let _first_continuation = first.next().await.unwrap().unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_lane_a_next", 2, 1).into(),
))
.await
.unwrap();
let _second_continuation = second.next().await.unwrap().unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_lane_b_next", 2, 1).into(),
))
.await
.unwrap();
first.close(None).await.unwrap();
second.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let warmup = |connection_id: &str| {
let mut body = Map::new();
body.insert("model".to_owned(), json!("gpt-5.5"));
body.insert("input".to_owned(), json!([]));
body.insert("generate".to_owned(), json!(false));
body.insert("store".to_owned(), json!(false));
let mut request = CodexResponsesRequest::from_body(body);
request.use_websocket = true;
request.local_conversation_id = Some("conversation-lanes".to_owned());
request.downstream_websocket_connection_id = Some(connection_id.to_owned());
request
};
let first_backend = backend.clone();
let first_request = warmup("ws_downstream_a");
let first = tokio::spawn(async move {
first_backend
.create_response(
&first_request,
request_context("req_lane_a_warmup", Some("chatgpt-account")),
)
.await
});
first_accepted_rx.await.unwrap();
let second = backend
.create_response(
&warmup("ws_downstream_b"),
request_context("req_lane_b_warmup", Some("chatgpt-account")),
)
.await
.expect("the second downstream lane should not contend with the first warmup");
let first = first.await.unwrap().unwrap();
let mut first_continuation = pooled_websocket_request("conversation-lanes");
first_continuation.downstream_websocket_connection_id = Some("ws_downstream_a".to_owned());
first_continuation.set_previous_response_id(Some("resp_lane_a".to_owned()));
first_continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
let first_continuation = backend
.create_response(
&first_continuation,
request_context("req_lane_a_next", Some("chatgpt-account")),
)
.await
.expect("the first lane should retain its connection-local response owner");
let mut second_continuation = pooled_websocket_request("conversation-lanes");
second_continuation.downstream_websocket_connection_id = Some("ws_downstream_b".to_owned());
second_continuation.set_previous_response_id(Some("resp_lane_b".to_owned()));
second_continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
let second_continuation = backend
.create_response(
&second_continuation,
request_context("req_lane_b_next", Some("chatgpt-account")),
)
.await
.expect("the second lane should retain its connection-local response owner");
server.await.unwrap();
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
assert_eq!(second.websocket_pool_decision.unwrap().kind(), "new");
assert_eq!(
first_continuation.websocket_pool_decision.unwrap().kind(),
"reuse"
);
assert_eq!(
second_continuation.websocket_pool_decision.unwrap().kind(),
"reuse"
);
assert_eq!(accepted_connections.load(Ordering::SeqCst), 2);
pool.shutdown().await;
}
#[tokio::test]
async fn exact_continuation_reuses_owning_socket_after_connection_profile_updates() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1108,6 +1241,30 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() {
.await
.expect("server should report the Pong-timeout close");
tokio::time::resume();
let mut continuation = request.clone();
continuation.set_previous_response_id(Some("resp_no_pong_first".to_owned()));
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
let error = backend
.create_response(
&continuation,
request_context("req_no_pong_continuation", Some("chatgpt-account")),
)
.await
.expect_err("Pong-timeout connection cannot satisfy an exact continuation");
let CodexClientError::WebSocket(error) = error else {
panic!("Pong-timeout continuation should remain a typed WebSocket error");
};
assert_eq!(
error.continuation_unavailable_reason(),
Some(PreviousResponseUnavailableReason::ReusedConnectionLost)
);
assert_eq!(
error
.connection_observation()
.expect("Pong-timeout tombstone should retain its lifecycle observation")
.exit_reason(),
"pong_timeout"
);
let second = backend
.create_response(
&request,
@@ -138,7 +138,11 @@ impl Provider for GrokBuildProvider {
self.catalog.catalog_generation()
}
fn request_observation(&self, operation: &Operation) -> ProviderRequestObservation {
fn request_observation(
&self,
operation: &Operation,
_client_api_key_id: &gateway_core::policy::ClientApiKeyId,
) -> ProviderRequestObservation {
let Operation::Generate(request) = operation else {
return ProviderRequestObservation::default();
};
@@ -153,6 +157,7 @@ impl Provider for GrokBuildProvider {
request_kind: semantics.request_kind,
subagent_kind: semantics.subagent_kind,
compact: semantics.compact,
continuation: Default::default(),
}
}

Some files were not shown because too many files have changed in this diff Show More