mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 08:06:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65fbbcb3ff | ||
|
|
bd086a7e3c | ||
|
|
3554ba13db | ||
|
|
bcccebc9fa | ||
|
|
d67b46a9e2 | ||
|
|
20c6d33cf4 | ||
|
|
cf0e84c548 | ||
|
|
2332af9a08 | ||
|
|
024fc9fcf0 | ||
|
|
4220f4e095 | ||
|
|
a709acb149 | ||
|
|
466842acd8 | ||
|
|
b91f28f95b | ||
|
|
87164db3da | ||
|
|
ad16dec3db | ||
|
|
a0da0f4713 | ||
|
|
65e5cde60d | ||
|
|
0f0b464f4e | ||
|
|
bd223f6540 | ||
|
|
168be0ca68 | ||
|
|
f2e91bbdaa | ||
|
|
d0c9248478 | ||
|
|
22c697bbff | ||
|
|
08d518324a | ||
|
|
dfacb20e43 |
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
"a,
|
||||
@@ -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
Reference in New Issue
Block a user