Files
Aether/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs
T

1950 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Per-turn lifecycle accounting for the standard Responses WebSocket bridge.
//!
//! Every `response.create` remains a separate billable and auditable request,
//! including turns that cause the bridge to re-plan a changed model. This
//! module turns the connection-local JSON events back into the existing
//! Responses stream report surface without exposing the socket protocol to the
//! normal HTTP/SSE execution runtime.
use std::collections::BTreeMap;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
use aether_contracts::{
ExecutionPlan, ExecutionStreamTerminalSummary, ExecutionTelemetry, ExecutionTimeouts,
MAX_EXECUTION_REQUEST_TIMEOUT_MS, MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_MS,
};
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
use aether_data_contracts::repository::usage::{
UsageBodyCaptureState, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY,
WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY,
};
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
use aether_usage_runtime::{
build_lifecycle_usage_seed, build_stream_terminal_usage_payload_seed,
build_terminal_usage_context_seed, stream_report_represents_failure,
DEFAULT_USAGE_RESPONSE_BODY_CAPTURE_LIMIT_BYTES,
};
use axum::http::StatusCode;
use base64::Engine as _;
use serde_json::{json, Map, Value};
use tracing::warn;
use super::adapter::ResponsesWebSocketProtocolAdapter;
use super::admission::ResponsesWebSocketTurnAdmission;
use super::frame::ParsedResponsesWebSocketFrame;
use super::observation::ResponsesStructuredTerminalObserver;
use super::settlement::attempt_facts_for_outcome;
use crate::ai_serving::{build_openai_responses_stream_plan_from_decision, AiExecutionDecision};
use crate::clock::current_unix_ms;
use crate::control::{
execution_plan_balance_capacity_rejection, GatewayControlDecision, GatewayLocalAuthRejection,
};
use crate::execution_runtime::attach_provider_response_headers_to_report_context;
use crate::execution_runtime::attempt_lifecycle::{
attempt_billing_is_void, AttemptBodyCapture, AttemptClientDelivery, AttemptLifecycleSeed,
AttemptProviderOutcome, AttemptStageGuard, AttemptTerminalFacts, AttemptTerminalFactsInput,
ExecutionAttemptLifecycle,
};
use crate::orchestration::{
apply_local_stream_failure_effects, apply_local_stream_success_effects,
release_local_pool_key_lease, release_pool_key_lease_from_report_context,
LocalExecutionEffectContext, LocalStreamFailureEffect,
};
use crate::plan_usage_policy::PlanUsagePolicySnapshot;
use crate::request_candidate_runtime::{
ensure_execution_request_candidate_slot, record_local_request_candidate_status,
};
use crate::usage::{submit_stream_report, GatewayStreamReportRequest};
use crate::{AppState, GatewayError};
const WEBSOCKET_CONNECTION_TRACE_REPORT_CONTEXT_FIELD: &str = "websocket_connection_trace_id";
const WEBSOCKET_TURN_INDEX_REPORT_CONTEXT_FIELD: &str = "websocket_turn_index";
const WEBSOCKET_LOGICAL_TURN_ID_REPORT_CONTEXT_FIELD: &str = "websocket_logical_turn_id";
const WEBSOCKET_TURN_ATTEMPT_REPORT_CONTEXT_FIELD: &str = "websocket_turn_attempt";
const WEBSOCKET_CLIENT_DELIVERY_REPORT_CONTEXT_FIELD: &str = "websocket_client_delivery";
const WEBSOCKET_CLIENT_DELIVERY_ABORTED: &str = "aborted";
const WEBSOCKET_CLIENT_DELIVERY_REASON_REPORT_CONTEXT_FIELD: &str =
"websocket_client_delivery_reason";
/// 首个可计费事件到达时记在 usage/candidate 上的状态码。WS 的首事件本身不带
/// HTTP 状态,沿用 HTTP 流式「已开始流」的 200。
const STREAM_STARTED_STATUS_CODE: u16 = 200;
const DEFAULT_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS: u64 = 30_000;
const RESPONSES_WEBSOCKET_LIFECYCLE_STAGE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResponsesWebSocketTurnObservation {
Started,
Terminal(ResponsesWebSocketTurnOutcome),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResponsesWebSocketTurnTimeoutPhase {
AwaitingFirstEvent,
AwaitingTerminal,
}
impl ResponsesWebSocketTurnTimeoutPhase {
pub(super) const fn error_code(self) -> &'static str {
match self {
Self::AwaitingFirstEvent => "responses_websocket_first_event_timeout",
Self::AwaitingTerminal => "responses_websocket_turn_timeout",
}
}
pub(super) const fn client_message(self) -> &'static str {
match self {
Self::AwaitingFirstEvent => {
"Provider did not emit a response event before the configured timeout"
}
Self::AwaitingTerminal => {
"Provider did not finish the response before the configured timeout"
}
}
}
pub(super) const fn outcome(self) -> ResponsesWebSocketTurnOutcome {
match self {
Self::AwaitingFirstEvent => ResponsesWebSocketTurnOutcome::first_event_timeout(),
Self::AwaitingTerminal => ResponsesWebSocketTurnOutcome::terminal_timeout(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ResponsesWebSocketTurnDeadline {
pub(super) phase: ResponsesWebSocketTurnTimeoutPhase,
pub(super) deadline: Instant,
pub(super) timeout: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResponsesWebSocketTurnOutcome {
ProviderTerminal {
status_code: u16,
cancelled: bool,
},
Cancelled {
reason: &'static str,
},
Failure {
status_code: u16,
reason: &'static str,
},
}
impl ResponsesWebSocketTurnOutcome {
pub(super) const fn client_disconnected() -> Self {
Self::Cancelled {
reason: "client disconnected before provider terminal event",
}
}
pub(super) const fn connection_limit_reached() -> Self {
Self::Cancelled {
reason: "gateway WebSocket connection duration limit reached",
}
}
pub(super) const fn connection_admission_lost() -> Self {
// This gateway-owned cancellation is also used when a logical turn's
// subscription-plan permit becomes unhealthy. Keeping it Cancelled
// prevents the settlement layer from projecting a provider failure;
// the client-visible 503 is emitted separately by plan_admission.
Self::Cancelled {
reason: "gateway WebSocket connection admission became unhealthy",
}
}
pub(super) const fn upstream_closed() -> Self {
Self::Failure {
status_code: 502,
reason: "upstream WebSocket closed before provider terminal event",
}
}
pub(super) const fn upstream_receive_failed() -> Self {
Self::Failure {
status_code: 502,
reason: "upstream WebSocket receive failed before provider terminal event",
}
}
pub(super) const fn upstream_send_failed() -> Self {
Self::Failure {
status_code: 502,
reason: "gateway could not forward response.create to the upstream",
}
}
pub(super) const fn upstream_connect_failed(reason: &'static str) -> Self {
Self::Failure {
status_code: 502,
reason,
}
}
pub(super) const fn provider_quota_exhausted() -> Self {
Self::Failure {
status_code: 429,
reason: "provider reported exhausted quota before closing the WebSocket",
}
}
pub(super) const fn first_event_timeout() -> Self {
Self::Failure {
status_code: 504,
reason: "upstream WebSocket did not emit a response event before timeout",
}
}
pub(super) const fn terminal_timeout() -> Self {
Self::Failure {
status_code: 504,
reason: "upstream WebSocket did not finish the response before timeout",
}
}
pub(super) const fn relay_task_abandoned() -> Self {
Self::Failure {
status_code: 500,
reason: "gateway relay task went away before the response finished",
}
}
pub(super) const fn relay_task_abandoned_before_upstream_send() -> Self {
Self::Cancelled {
reason: "gateway abandoned the turn before sending response.create upstream",
}
}
pub(super) const fn relay_task_abandonment(upstream_request_sent: bool) -> Self {
if upstream_request_sent {
Self::relay_task_abandoned()
} else {
Self::relay_task_abandoned_before_upstream_send()
}
}
const fn status_code(self) -> u16 {
match self {
Self::ProviderTerminal { status_code, .. } | Self::Failure { status_code, .. } => {
status_code
}
Self::Cancelled { .. } => 499,
}
}
}
/// 一次 provider attempt:一条上游执行,也是一条独立的 usage/candidate 记录。
///
/// 与 [`super::turn_state::LogicalTurn`] 分工明确:logical turn 是客户端看到的
/// 一轮请求(可能包含多个 attempt),attempt 只负责这一次上游执行的记账事实。
pub(super) struct ResponsesProviderAttempt {
/// 记账三段(pending / started / terminal)由共享的 transport 中立生命周期负责。
lifecycle: ExecutionAttemptLifecycle,
started_at: Instant,
provider_request_started_at_unix_ms: u64,
provider_request_order_id: String,
provider_headers: BTreeMap<String, String>,
observer: ResponsesStructuredTerminalObserver,
provider_capture: AttemptBodyCapture,
client_capture: AttemptBodyCapture,
upstream_bytes: u64,
first_event_elapsed_ms: Option<u64>,
first_event_timeout: Duration,
terminal_timeout: Duration,
admission: Option<ResponsesWebSocketTurnAdmission>,
/// A provider attempt gets its own server-issued cost reservation. The
/// trusted token also lives in the lifecycle report context so the normal
/// terminal usage path can reconcile actual cost. We retain the control
/// snapshot here only for the quota-retry path, which must release the old
/// attempt synchronously before reserving the replacement.
plan_usage_cost_reservation: Option<ResponsesPlanUsageCostReservation>,
terminal_error_body: Option<String>,
/// 观察到的 provider 终态事实,与「为什么现在结算」这个信号分开保存。
/// 客户端投递失败不会把它擦掉。
provider_outcome: Option<AttemptProviderOutcome>,
/// 这一个 attempt 的内容是否完整交付给了客户端。与 provider 终态正交。
client_delivery: AttemptClientDelivery,
/// Tracks the irreversible transport handoff separately from flush
/// confirmation. Once start_send succeeds, a later cancellation cannot
/// prove the provider did not receive or execute response.create.
upstream_request_state: UpstreamRequestState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UpstreamRequestState {
NotStarted,
PossiblySent,
Sent,
}
impl UpstreamRequestState {
const fn may_have_reached_provider(self) -> bool {
!matches!(self, Self::NotStarted)
}
}
struct ResponsesPlanUsageCostReservation {
control_decision: GatewayControlDecision,
reservation_token: String,
}
struct OwnedResponsesPlanUsageCostReservation {
control_decision: GatewayControlDecision,
plan: ExecutionPlan,
reservation_token: String,
}
/// Owns a successful durable reservation until an attempt lifecycle owns the
/// trusted report context. If startup is cancelled anywhere in between, Drop
/// releases the reservation instead of leaving a false 429 until its TTL.
struct ResponsesPlanUsageCostReservationGuard {
state: AppState,
reservation: Option<OwnedResponsesPlanUsageCostReservation>,
}
impl ResponsesPlanUsageCostReservationGuard {
fn new(
state: &AppState,
control_decision: GatewayControlDecision,
plan: ExecutionPlan,
reservation_token: String,
) -> Self {
Self {
state: state.clone(),
reservation: Some(OwnedResponsesPlanUsageCostReservation {
control_decision,
plan,
reservation_token,
}),
}
}
fn reservation_token(&self) -> &str {
self.reservation
.as_ref()
.expect("an armed plan cost reservation guard owns its reservation")
.reservation_token
.as_str()
}
fn disarm(mut self) -> ResponsesPlanUsageCostReservation {
let reservation = self
.reservation
.take()
.expect("an armed plan cost reservation guard owns its reservation");
ResponsesPlanUsageCostReservation {
control_decision: reservation.control_decision,
reservation_token: reservation.reservation_token,
}
}
}
impl Drop for ResponsesPlanUsageCostReservationGuard {
fn drop(&mut self) {
let Some(reservation) = self.reservation.take() else {
return;
};
let state = self.state.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
release_responses_plan_usage_cost_best_effort(
&state,
&reservation.control_decision,
&reservation.plan,
reservation.reservation_token.as_str(),
"turn_start_cancelled",
)
.await;
});
}
}
}
enum ResponsesPlanUsageCostReservationStart {
NotRequired,
Reserved(ResponsesPlanUsageCostReservationGuard),
Rejected(crate::plan_usage_policy::PlanUsagePolicyRejection),
}
async fn reserve_responses_plan_usage_cost_owned(
state: &AppState,
control_decision: &GatewayControlDecision,
plan: &ExecutionPlan,
report_context: Option<&Value>,
plan_usage_policy_snapshot: Option<&PlanUsagePolicySnapshot>,
reservation_token: String,
) -> Result<ResponsesPlanUsageCostReservationStart, GatewayError> {
let outcome = crate::plan_usage_policy::reserve_admitted_plan_usage_policy_cost(
state,
control_decision,
plan,
report_context,
plan_usage_policy_snapshot,
reservation_token.as_str(),
)
.await?;
match outcome {
crate::plan_usage_policy::PlanUsageCostReservationOutcome::NotRequired => {
Ok(ResponsesPlanUsageCostReservationStart::NotRequired)
}
crate::plan_usage_policy::PlanUsageCostReservationOutcome::Reserved => {
Ok(ResponsesPlanUsageCostReservationStart::Reserved(
ResponsesPlanUsageCostReservationGuard::new(
state,
control_decision.clone(),
plan.clone(),
reservation_token,
),
))
}
crate::plan_usage_policy::PlanUsageCostReservationOutcome::Rejected(rejection) => {
Ok(ResponsesPlanUsageCostReservationStart::Rejected(rejection))
}
}
}
fn attach_plan_usage_reservation_token(
report_context: Option<Value>,
reservation_token: &str,
) -> Option<Value> {
let mut object = match report_context {
Some(Value::Object(object)) => object,
Some(other) => Map::from_iter([("seed".to_string(), other)]),
None => Map::new(),
};
// Always overwrite a client-derived/seed value with the server-issued
// token. Terminal reconciliation must never trust caller-controlled JSON.
object.insert(
"plan_usage_reservation_token".to_string(),
Value::String(reservation_token.to_string()),
);
// The token and its reconciliation controls are server-owned. A seed or
// client event must never be able to defer reconciliation on its own.
object.insert(
PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY.to_string(),
Value::Bool(false),
);
Some(Value::Object(object))
}
fn websocket_plan_usage_rejection_error(
rejection: &crate::plan_usage_policy::PlanUsagePolicyRejection,
) -> GatewayError {
GatewayError::PlanUsageLimited(rejection.clone())
}
async fn release_responses_plan_usage_cost(
state: &AppState,
control_decision: &GatewayControlDecision,
plan: &ExecutionPlan,
reservation_token: &str,
_reason: &'static str,
) -> Result<(), GatewayError> {
crate::plan_usage_policy::release_plan_usage_policy_cost(
state,
control_decision,
plan,
reservation_token,
current_unix_ms() / 1_000,
)
.await
}
async fn release_responses_plan_usage_cost_best_effort(
state: &AppState,
control_decision: &GatewayControlDecision,
plan: &ExecutionPlan,
reservation_token: &str,
reason: &'static str,
) {
if let Err(error) =
release_responses_plan_usage_cost(state, control_decision, plan, reservation_token, reason)
.await
{
warn!(
event_name = "responses_websocket_plan_usage_cost_release_failed",
log_type = "ops",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
reservation_token,
reason,
error = ?error,
"gateway failed to release a Responses WebSocket plan cost reservation"
);
}
}
/// 组装一轮 turn 的 decision。
///
/// `effective_client_event` 必须是**已经过请求侧脱敏**的客户端事件(见
/// `super::redaction`),`provider_event` 由它派生。审计里的 `original_request_body`
/// 直接用它覆盖 seed:continuation 的 seed 来自绑定那一轮的 report_context,不覆盖
/// 会记成上一轮的 body;但覆盖成 raw 事件又会把已脱敏的审计内容换回原文,等于
/// 脱敏在审计侧失效。
pub(super) fn prepare_responses_websocket_turn_decision(
template: &AiExecutionDecision,
request_id: String,
reuse_selected_candidate: bool,
effective_client_event: &Value,
provider_event: &Value,
connection_trace_id: &str,
turn_index: u64,
logical_turn_id: &str,
turn_attempt: u32,
) -> AiExecutionDecision {
let mut decision = template.clone();
decision.request_id = Some(request_id.clone());
if !reuse_selected_candidate {
decision.candidate_id = None;
}
decision.provider_request_body = Some(provider_event.clone());
decision.provider_request_body_base64 = None;
decision.report_context = Some(prepare_websocket_report_context(
decision.report_context.take(),
request_id.as_str(),
reuse_selected_candidate,
effective_client_event,
provider_event,
connection_trace_id,
turn_index,
logical_turn_id,
turn_attempt,
));
decision
}
pub(super) async fn begin_unowned_responses_websocket_turn(
state: &AppState,
parts: &http::request::Parts,
control_decision: &GatewayControlDecision,
decision: AiExecutionDecision,
client_event: &Value,
plan_usage_policy_snapshot: Option<PlanUsagePolicySnapshot>,
) -> Result<ResponsesProviderAttempt, GatewayError> {
let planned_report_context = decision.report_context.clone();
let attempt = match build_openai_responses_stream_plan_from_decision(
parts,
client_event,
decision,
false,
) {
Ok(Some(attempt)) => attempt,
Ok(None) => {
release_pool_key_lease_from_report_context(state, planned_report_context.as_ref())
.await;
return Err(GatewayError::Internal(
"Responses WebSocket request could not build a usage/audit stream plan".to_string(),
));
}
Err(error) => {
release_pool_key_lease_from_report_context(state, planned_report_context.as_ref())
.await;
return Err(error);
}
};
let mut plan = attempt.plan;
let (first_event_timeout, terminal_timeout) =
resolve_responses_websocket_turn_timeouts(plan.timeouts.as_ref());
let report_kind = match attempt.report_kind {
Some(report_kind) => report_kind,
None => {
release_local_pool_key_lease(
state,
LocalExecutionEffectContext {
plan: &plan,
report_context: attempt.report_context.as_ref(),
},
)
.await;
return Err(GatewayError::Internal(
"Responses WebSocket request is missing an execution report kind".to_string(),
));
}
};
let mut report_context = attempt.report_context;
let balance_rejection = execution_plan_balance_capacity_rejection(
state,
control_decision,
&plan,
report_context.as_ref(),
)
.await;
let balance_rejection = match balance_rejection {
Ok(rejection) => rejection,
Err(error) => {
release_local_pool_key_lease(
state,
LocalExecutionEffectContext {
plan: &plan,
report_context: report_context.as_ref(),
},
)
.await;
return Err(error);
}
};
if let Some(rejection) = balance_rejection {
release_local_pool_key_lease(
state,
LocalExecutionEffectContext {
plan: &plan,
report_context: report_context.as_ref(),
},
)
.await;
return Err(websocket_auth_rejection_error(rejection));
}
let candidate_started_at_unix_ms = current_unix_ms();
ensure_execution_request_candidate_slot(state, &mut plan, &mut report_context).await;
let admission = match ResponsesWebSocketTurnAdmission::acquire(
state,
&plan,
plan.request_id.as_str(),
)
.await
{
Ok(admission) => admission,
Err(error) => {
release_then_record_responses_websocket_admission_failure(
release_local_pool_key_lease(
state,
LocalExecutionEffectContext {
plan: &plan,
report_context: report_context.as_ref(),
},
),
record_responses_websocket_admission_failure(
state,
&plan,
report_context.as_ref(),
candidate_started_at_unix_ms,
&error,
),
)
.await;
return Err(error);
}
};
let reservation_token = uuid::Uuid::new_v4().to_string();
let cost_reservation = match reserve_responses_plan_usage_cost_owned(
state,
control_decision,
&plan,
report_context.as_ref(),
plan_usage_policy_snapshot.as_ref(),
reservation_token,
)
.await
{
Ok(ResponsesPlanUsageCostReservationStart::NotRequired) => None,
Ok(ResponsesPlanUsageCostReservationStart::Reserved(guard)) => {
report_context =
attach_plan_usage_reservation_token(report_context, guard.reservation_token());
Some(guard)
}
Ok(ResponsesPlanUsageCostReservationStart::Rejected(rejection)) => {
let error = websocket_plan_usage_rejection_error(&rejection);
admission.release().await;
release_then_record_responses_websocket_admission_failure(
release_local_pool_key_lease(
state,
LocalExecutionEffectContext {
plan: &plan,
report_context: report_context.as_ref(),
},
),
record_responses_websocket_admission_failure(
state,
&plan,
report_context.as_ref(),
candidate_started_at_unix_ms,
&error,
),
)
.await;
return Err(error);
}
Err(error) => {
admission.release().await;
release_then_record_responses_websocket_admission_failure(
release_local_pool_key_lease(
state,
LocalExecutionEffectContext {
plan: &plan,
report_context: report_context.as_ref(),
},
),
record_responses_websocket_admission_failure(
state,
&plan,
report_context.as_ref(),
candidate_started_at_unix_ms,
&error,
),
)
.await;
return Err(error);
}
};
let lifecycle = ExecutionAttemptLifecycle::begin(
state,
AttemptLifecycleSeed {
plan,
report_kind,
report_context,
// relay loop 是单任务:一段慢依赖会拖住整条连接的收发,所以每段
// 记账 I/O 都要有等待上界。
stage_guard: AttemptStageGuard::Bounded(RESPONSES_WEBSOCKET_LIFECYCLE_STAGE_TIMEOUT),
},
)
.await;
let plan_usage_cost_reservation = cost_reservation.map(|guard| guard.disarm());
Ok(ResponsesProviderAttempt {
lifecycle,
started_at: Instant::now(),
provider_request_started_at_unix_ms: current_unix_ms(),
provider_request_order_id: uuid::Uuid::now_v7().to_string(),
provider_headers: BTreeMap::new(),
observer: ResponsesStructuredTerminalObserver::default(),
provider_capture: AttemptBodyCapture::default(),
client_capture: AttemptBodyCapture::default(),
upstream_bytes: 0,
first_event_elapsed_ms: None,
first_event_timeout,
terminal_timeout,
admission: Some(admission),
plan_usage_cost_reservation,
terminal_error_body: None,
provider_outcome: None,
client_delivery: AttemptClientDelivery::Complete,
upstream_request_state: UpstreamRequestState::NotStarted,
})
}
async fn release_then_record_responses_websocket_admission_failure(
release_pool_lease: impl Future<Output = ()>,
record_candidate_failure: impl Future<Output = ()>,
) {
// Lease cleanup protects live routing capacity and must not sit behind a
// slow candidate writer. The candidate write still follows immediately so
// the seeded row reaches a terminal state on the ordinary error path.
release_pool_lease.await;
record_candidate_failure.await;
}
async fn record_responses_websocket_admission_failure(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&Value>,
candidate_started_at_unix_ms: u64,
error: &GatewayError,
) {
let terminal_at_unix_ms = current_unix_ms();
record_local_request_candidate_status(
state,
plan,
report_context,
responses_websocket_admission_failure_update(
candidate_started_at_unix_ms,
terminal_at_unix_ms,
error,
),
)
.await;
}
fn responses_websocket_admission_failure_update(
candidate_started_at_unix_ms: u64,
terminal_at_unix_ms: u64,
error: &GatewayError,
) -> SchedulerRequestCandidateStatusUpdate {
let (status_code, error_type, error_message) = match error {
GatewayError::AdmissionTimeout {
gate,
queue_budget_ms,
..
} => (
StatusCode::TOO_MANY_REQUESTS.as_u16(),
"gateway_admission_timeout",
format!("gateway admission gate {gate} timed out after {queue_budget_ms}ms"),
),
GatewayError::PlanUsageLimited(_) => (
StatusCode::TOO_MANY_REQUESTS.as_u16(),
"plan_usage_limit_exceeded",
"subscription plan usage limit exceeded".to_string(),
),
other => (
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"gateway_admission_failed",
format!("{other:?}"),
),
};
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
status_code: Some(status_code),
error_type: Some(error_type.to_string()),
error_message: Some(error_message),
latency_ms: Some(terminal_at_unix_ms.saturating_sub(candidate_started_at_unix_ms)),
started_at_unix_ms: Some(candidate_started_at_unix_ms),
finished_at_unix_ms: Some(terminal_at_unix_ms),
}
}
fn websocket_auth_rejection_error(rejection: GatewayLocalAuthRejection) -> GatewayError {
let (status, message) = match rejection {
GatewayLocalAuthRejection::InvalidApiKey => {
(StatusCode::UNAUTHORIZED, "The API key is invalid")
}
GatewayLocalAuthRejection::LockedApiKey => (
StatusCode::FORBIDDEN,
"The API key is locked and cannot be used",
),
GatewayLocalAuthRejection::WalletUnavailable => {
(StatusCode::FORBIDDEN, "The account wallet is unavailable")
}
GatewayLocalAuthRejection::BalanceDenied { remaining } => {
let message = match remaining {
Some(remaining) => format!("Insufficient balance (remaining: ${remaining:.2})"),
None => "Insufficient balance".to_string(),
};
return GatewayError::Client {
status: StatusCode::TOO_MANY_REQUESTS,
message,
};
}
GatewayLocalAuthRejection::ProviderNotAllowed { .. } => (
StatusCode::FORBIDDEN,
"The provider is not allowed for this API key",
),
GatewayLocalAuthRejection::ApiFormatNotAllowed { .. } => (
StatusCode::FORBIDDEN,
"The API format is not allowed for this API key",
),
GatewayLocalAuthRejection::ModelNotAllowed { .. } => (
StatusCode::FORBIDDEN,
"The requested model is not allowed for this API key",
),
GatewayLocalAuthRejection::IpNotAllowed { .. } => (
StatusCode::UNAUTHORIZED,
"The current IP is not allowed for this API key",
),
};
GatewayError::Client {
status,
message: message.to_string(),
}
}
impl ResponsesProviderAttempt {
pub(super) fn cancel_on_client_disconnect(&self) -> bool {
crate::orchestration::routing_execution_policy_from_report_context(
self.lifecycle.report_context(),
)
.is_some_and(|policy| policy.cancel_on_client_disconnect)
}
/// Releases all per-turn capacity before terminal persistence starts.
/// Provider-pool runtime tokens normally use an awaited removal. The
/// bounded wait prevents a broken runtime backend from stalling the relay;
/// the guard's `Drop` path remains the timeout fallback.
pub(super) async fn release_admission(&mut self) {
if let Some(admission) = self.admission.take() {
let _ = self
.lifecycle
.stage_guard()
.await_stage(
self.lifecycle.trace_id(),
"turn_admission_release",
admission.release(),
)
.await;
}
}
/// Releases this attempt's reserved cost before a transparent retry. The
/// terminal usage write that follows may reconcile the same token again;
/// repository reconciliation is idempotent, so the first Released state
/// wins and the replacement attempt can reserve independently.
pub(super) async fn release_plan_usage_cost_for_retry(
&mut self,
state: &AppState,
) -> Result<(), GatewayError> {
if self.upstream_request_state.may_have_reached_provider()
&& self.provider_outcome.is_none()
{
return Err(GatewayError::Internal(
"cannot release an unresolved plan cost reservation after the upstream request may have been sent"
.to_string(),
));
}
let Some(reservation) = self.plan_usage_cost_reservation.take() else {
return Ok(());
};
let release = release_responses_plan_usage_cost(
state,
&reservation.control_decision,
self.lifecycle.plan(),
reservation.reservation_token.as_str(),
"transparent_retry",
)
.await;
if release.is_err() {
self.plan_usage_cost_reservation = Some(reservation);
}
release
}
/// A transparent replacement failed before its request reached an
/// upstream. No terminal usage event can safely own the still-reserved
/// estimate, so release it explicitly before the orphan attempt is
/// finalized or dropped.
pub(super) async fn release_plan_usage_cost_before_upstream_send(
&mut self,
state: &AppState,
reason: &'static str,
) {
if self.upstream_request_state.may_have_reached_provider() {
return;
}
let Some(reservation) = self.plan_usage_cost_reservation.take() else {
return;
};
release_responses_plan_usage_cost_best_effort(
state,
&reservation.control_decision,
self.lifecycle.plan(),
reservation.reservation_token.as_str(),
reason,
)
.await;
}
pub(super) fn set_provider_response_headers(&mut self, headers: BTreeMap<String, String>) {
let observed_at_unix_ms = current_unix_ms();
let report_context = attach_provider_response_headers_to_report_context(
self.lifecycle.take_report_context(),
&headers,
self.provider_request_started_at_unix_ms,
observed_at_unix_ms,
&self.provider_request_order_id,
);
self.lifecycle.set_report_context(report_context);
self.provider_headers = headers;
}
/// Starts the per-turn response deadlines only after the corresponding
/// `response.create` has been accepted by the upstream socket writer.
pub(super) fn record_upstream_request_state(&mut self, state: UpstreamRequestState) {
match state {
UpstreamRequestState::NotStarted => {
debug_assert!(
false,
"upstream request state cannot move back to not started"
);
}
UpstreamRequestState::PossiblySent => {
debug_assert_eq!(
self.upstream_request_state,
UpstreamRequestState::NotStarted
);
self.upstream_request_state = state;
self.started_at = Instant::now();
self.provider_request_started_at_unix_ms = current_unix_ms();
self.provider_request_order_id = uuid::Uuid::now_v7().to_string();
self.first_event_elapsed_ms = None;
}
UpstreamRequestState::Sent => {
debug_assert_eq!(
self.upstream_request_state,
UpstreamRequestState::PossiblySent
);
self.upstream_request_state = state;
}
}
}
/// Selects a cancellation-safe fallback for an attempt whose owner task
/// disappeared. Before the upstream write this is a void cancellation;
/// after the write it remains a gateway relay failure because provider
/// work may already have started.
pub(super) const fn abandonment_outcome(&self) -> ResponsesWebSocketTurnOutcome {
ResponsesWebSocketTurnOutcome::relay_task_abandonment(
self.upstream_request_state.may_have_reached_provider(),
)
}
pub(super) fn deadline(&self) -> ResponsesWebSocketTurnDeadline {
let (phase, timeout) = if self.first_event_elapsed_ms.is_some() {
(
ResponsesWebSocketTurnTimeoutPhase::AwaitingTerminal,
self.terminal_timeout,
)
} else {
(
ResponsesWebSocketTurnTimeoutPhase::AwaitingFirstEvent,
self.first_event_timeout.min(self.terminal_timeout),
)
};
ResponsesWebSocketTurnDeadline {
phase,
deadline: self.started_at + timeout,
timeout,
}
}
pub(super) fn observe_upstream_frame(
&mut self,
frame: &ParsedResponsesWebSocketFrame<'_>,
adapter: &dyn ResponsesWebSocketProtocolAdapter,
) -> Option<ResponsesWebSocketTurnObservation> {
self.upstream_bytes = self
.upstream_bytes
.saturating_add(frame.raw_text().len() as u64);
if self.first_event_elapsed_ms.is_none() {
self.first_event_elapsed_ms = Some(elapsed_ms(self.started_at));
}
// 一帧可以带多个协议事件(批量帧),必须拆开:观测器按事件推进状态机,
// 整帧当一个事件喂会丢掉批量里最后那个 completed 的 usage。
let events = frame.protocol_events();
let mut report_context = self.lifecycle.take_report_context();
for event in &events {
// 观测已经走结构化入口,但捕获仍然必须是 SSE 形状:usage runtime 按
// `data:` 行解析被捕获的 body 判定终态。
self.capture_sse_event(event);
adapter.decorate_turn_report_context(&mut report_context, event);
}
self.lifecycle.set_report_context(report_context);
let fallback_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let report_context = self.lifecycle.report_context().unwrap_or(&fallback_context);
self.observer.observe_events(report_context, &events);
let event_type = frame.event_type().unwrap_or_default();
if matches!(event_type, "error" | "response.failed") {
self.terminal_error_body = frame
.terminal_event()
.and_then(|event| serde_json::to_string(event).ok());
}
if let Some(outcome) = provider_terminal_outcome(frame) {
// provider 的终态是独立事实:先记下来,之后即使客户端投递失败、
// 结算信号变成 Cancelled,这条事实也不会被擦掉。
self.provider_outcome.get_or_insert(
attempt_facts_for_outcome(None, AttemptClientDelivery::Complete, outcome).provider,
);
return Some(ResponsesWebSocketTurnObservation::Terminal(outcome));
}
if frame.is_started() {
return Some(ResponsesWebSocketTurnObservation::Started);
}
None
}
pub(super) fn observe_invalid_upstream_text(
&mut self,
text: &str,
) -> Option<ResponsesWebSocketTurnObservation> {
self.upstream_bytes = self.upstream_bytes.saturating_add(text.len() as u64);
if self.first_event_elapsed_ms.is_none() {
self.first_event_elapsed_ms = Some(elapsed_ms(self.started_at));
}
self.capture_sse_event(&json!({
"type": "error",
"error": {
"type": "gateway_protocol_error",
"message": "upstream Responses WebSocket event was not valid JSON"
}
}));
self.observer
.disable_with_error("upstream Responses WebSocket event was not valid JSON");
Some(ResponsesWebSocketTurnObservation::Terminal(
ResponsesWebSocketTurnOutcome::Failure {
status_code: 502,
reason: "upstream Responses WebSocket event was not valid JSON",
},
))
}
/// 记录「这一个 attempt 的内容没能完整交付给客户端」。
///
/// 与 provider 终态分开记录:供应商已经给出终态时,这条事实只影响
/// candidate 的错误分类和审计里的投递标记,不作废账单。
pub(super) fn record_client_delivery_aborted(&mut self, reason: &'static str) {
if matches!(self.client_delivery, AttemptClientDelivery::Complete) {
self.client_delivery = AttemptClientDelivery::Aborted { reason };
}
}
pub(super) fn capture_client_frame(&mut self, event: &Value) {
self.client_capture
.append(&websocket_event_as_sse_line(event));
}
pub(super) async fn mark_stream_started(&mut self, state: &AppState) {
let telemetry = self.telemetry();
self.lifecycle
.mark_started(state, STREAM_STARTED_STATUS_CODE, &telemetry)
.await;
}
/// 结算这一个 attempt。
///
/// `outcome` 是「为什么现在结算」的信号,不是供应商事实本身:
/// [`attempt_facts_for_outcome`] 把它和已观察到的 provider 终态、已记录的
/// 投递结果一起,拆成 provider outcome 与 client delivery 两个正交事实。
/// 之后的四段记账(usage terminal → candidate terminal → provider 效果 →
/// execution report)由共享的 [`ExecutionAttemptLifecycle::settle`] 负责,
/// 这里只提供 WS 观察到的终态事实。
async fn settle(mut self, state: &AppState, outcome: ResponsesWebSocketTurnOutcome) {
let facts = attempt_facts_for_outcome(self.provider_outcome, self.client_delivery, outcome);
if self.upstream_request_state.may_have_reached_provider()
&& !facts.provider.is_terminal()
&& self.plan_usage_cost_reservation.is_some()
{
let report_context =
attach_plan_usage_reservation_deferred(self.lifecycle.take_report_context());
self.lifecycle.set_report_context(report_context);
}
if let Some(reason) = facts.delivery.aborted_reason() {
let report_context = attach_client_delivery_to_report_context(
self.lifecycle.take_report_context(),
reason,
);
self.lifecycle.set_report_context(report_context);
}
let summary = self.finish_summary(facts);
let telemetry = self.telemetry();
let terminal_error_body = self.terminal_error_body.take();
// 终态载荷完整了才释放准入:usage/审计写入期间不再占着 gateway/供应商容量。
if let Some(admission) = self.admission.take() {
let _ = self
.lifecycle
.stage_guard()
.await_stage(
self.lifecycle.trace_id(),
"turn_admission_release",
admission.release(),
)
.await;
}
self.lifecycle
.settle(
state,
AttemptTerminalFactsInput {
facts,
terminal_summary: summary,
telemetry,
provider_headers: std::mem::take(&mut self.provider_headers),
provider_body: &self.provider_capture,
client_body: &self.client_capture,
provider_error_body: terminal_error_body.as_deref(),
reason: facts.reason(),
},
)
.await;
}
fn capture_sse_event(&mut self, event: &Value) {
self.provider_capture
.append(&websocket_event_as_sse_line(event));
}
/// 终态摘要。
///
/// 只消费两个正交事实:`forced_error` 只在「供应商没给出终态且内容已完整
/// 交付」时补 parser_error,`cancelled` 覆盖供应商声明取消与客户端投递失败
/// 两种情形——与拆分前 `outcome.forced_error()` / `outcome.cancelled()` 的
/// 取值逐一对应。
fn finish_summary(&mut self, facts: AttemptTerminalFacts) -> ExecutionStreamTerminalSummary {
let fallback_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let report_context = self.lifecycle.report_context().unwrap_or(&fallback_context);
let mut summary = self.observer.finish(report_context);
if let Some(reason) = facts.forced_error() {
if summary.parser_error.is_none() {
summary.parser_error = Some(reason.to_string());
}
}
// 只有作废账单的那一侧才把摘要改写成 cancelled。provider 终态已到达时
// 摘要必须保留真实的 finish_reason 和 usage,否则计费记录会被写坏。
if attempt_billing_is_void(facts) {
summary.observed_finish = true;
if summary.finish_reason.is_none() {
summary.finish_reason = Some("cancelled".to_string());
}
} else if !summary.observed_finish && summary.parser_error.is_none() {
summary.parser_error = Some(
"upstream Responses WebSocket ended before a provider terminal event".to_string(),
);
}
summary
}
fn telemetry(&self) -> ExecutionTelemetry {
ExecutionTelemetry {
ttfb_ms: self.first_event_elapsed_ms,
elapsed_ms: Some(elapsed_ms(self.started_at)),
upstream_bytes: Some(self.upstream_bytes),
}
}
}
impl ResponsesProviderAttempt {
/// Finalizes a turn whose owner is already gone, releasing admission first.
///
/// The normal path releases admission before spawning the finalizer; a
/// turn reclaimed from a lost relay task has to do both itself.
pub(super) async fn finalize_detached(
mut self,
state: &AppState,
outcome: ResponsesWebSocketTurnOutcome,
) {
self.release_admission().await;
self.settle(state, outcome).await;
}
}
/// 把一轮 turn 的事实写进审计/用量 report context。
///
/// `effective_client_event` 是脱敏后的客户端事件(未启用脱敏时就是原事件)。
/// HTTP 路径的约定是「脱敏生效时审计记录脱敏后的 body」
/// (`ai_serving/planner/standard/openai/responses/decision/payload.rs`),
/// WS 这里必须保持一致,否则上游收到的是脱敏内容、审计里却留着原始 PII。
fn prepare_websocket_report_context(
report_context: Option<Value>,
request_id: &str,
reuse_selected_candidate: bool,
effective_client_event: &Value,
provider_event: &Value,
connection_trace_id: &str,
turn_index: u64,
logical_turn_id: &str,
turn_attempt: u32,
) -> Value {
let mut object = match report_context {
Some(Value::Object(object)) => object,
Some(other) => Map::from_iter([("seed".to_string(), other)]),
None => Map::new(),
};
object.insert(
"request_id".to_string(),
Value::String(request_id.to_string()),
);
if !reuse_selected_candidate {
for field in [
"candidate_id",
"candidate_index",
"retry_index",
"pool_key_index",
"candidate_group_id",
"pool_key_lease_key",
"pool_key_lease_owner",
"pool_key_lease_token",
"pool_key_lease_fencing_token",
"pool_key_lease_ttl_ms",
"scheduler_affinity_epoch",
] {
object.remove(field);
}
}
object.insert(
"original_request_body".to_string(),
effective_client_event.clone(),
);
if let Some(model) = effective_client_event
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
{
object.insert("model".to_string(), Value::String(model.to_string()));
}
if let Some(mapped_model) = provider_event
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
{
object.insert(
"mapped_model".to_string(),
Value::String(mapped_model.to_string()),
);
}
object.insert(WEBSOCKET_MODE_METADATA_KEY.to_string(), Value::Bool(true));
object.insert(
WEBSOCKET_CONNECTION_TRACE_REPORT_CONTEXT_FIELD.to_string(),
Value::String(connection_trace_id.to_string()),
);
object.insert(
WEBSOCKET_TURN_INDEX_REPORT_CONTEXT_FIELD.to_string(),
Value::Number(turn_index.into()),
);
object.insert(
WEBSOCKET_LOGICAL_TURN_ID_REPORT_CONTEXT_FIELD.to_string(),
Value::String(logical_turn_id.to_string()),
);
object.insert(
WEBSOCKET_TURN_ATTEMPT_REPORT_CONTEXT_FIELD.to_string(),
Value::Number(turn_attempt.into()),
);
object.insert(
WEBSOCKET_TRANSPORT_METADATA_KEY.to_string(),
Value::String("responses".to_string()),
);
Value::Object(object)
}
/// 在审计/用量 report context 上标记这一 attempt 的内容没能交付给客户端。
///
/// 只增字段,不改既有字段:账单本身按 provider 终态计,投递失败作为独立事实
/// 留在记录里,便于事后区分「客户端拿到了」和「客户端没拿到但已计费」。
fn attach_client_delivery_to_report_context(
report_context: Option<Value>,
reason: &str,
) -> Option<Value> {
let mut object = match report_context {
Some(Value::Object(object)) => object,
Some(other) => Map::from_iter([("seed".to_string(), other)]),
None => Map::new(),
};
object.insert(
WEBSOCKET_CLIENT_DELIVERY_REPORT_CONTEXT_FIELD.to_string(),
Value::String(WEBSOCKET_CLIENT_DELIVERY_ABORTED.to_string()),
);
object.insert(
WEBSOCKET_CLIENT_DELIVERY_REASON_REPORT_CONTEXT_FIELD.to_string(),
Value::String(reason.to_string()),
);
Some(Value::Object(object))
}
fn attach_plan_usage_reservation_deferred(report_context: Option<Value>) -> Option<Value> {
let mut object = match report_context {
Some(Value::Object(object)) => object,
Some(other) => Map::from_iter([("seed".to_string(), other)]),
None => Map::new(),
};
object.insert(
PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY.to_string(),
Value::Bool(true),
);
Some(Value::Object(object))
}
fn provider_terminal_outcome(
frame: &ParsedResponsesWebSocketFrame<'_>,
) -> Option<ResponsesWebSocketTurnOutcome> {
frame
.terminal()
.map(|terminal| ResponsesWebSocketTurnOutcome::ProviderTerminal {
status_code: terminal.status_code,
cancelled: terminal.cancelled,
})
}
fn resolve_responses_websocket_turn_timeouts(
timeouts: Option<&ExecutionTimeouts>,
) -> (Duration, Duration) {
let first_event_timeout_ms = timeouts
.and_then(|timeouts| timeouts.first_byte_ms)
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS)
.min(MAX_EXECUTION_STREAM_FIRST_BYTE_TIMEOUT_MS);
let terminal_timeout_ms = timeouts
.and_then(|timeouts| timeouts.total_ms)
.filter(|value| *value > 0)
.unwrap_or(MAX_EXECUTION_REQUEST_TIMEOUT_MS)
.min(MAX_EXECUTION_REQUEST_TIMEOUT_MS);
(
Duration::from_millis(first_event_timeout_ms),
Duration::from_millis(terminal_timeout_ms),
)
}
fn websocket_event_as_sse_line(event: &Value) -> Vec<u8> {
let payload = serde_json::to_string(event).unwrap_or_else(|_| {
json!({
"type": "error",
"error": {
"type": "gateway_protocol_error",
"message": "upstream Responses WebSocket event could not be serialized"
}
})
.to_string()
});
format!("data: {payload}\n\n").into_bytes()
}
fn elapsed_ms(started_at: Instant) -> u64 {
started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use aether_contracts::ExecutionTimeouts;
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
use serde_json::json;
use super::super::observation::ResponsesStructuredTerminalObserver;
use super::super::frame::ParsedResponsesWebSocketFrame;
use super::super::settlement::{
attempt_facts_for_outcome, settle_signal_for_client_delivery_failure,
};
use super::{
attach_client_delivery_to_report_context, attach_plan_usage_reservation_deferred,
attach_plan_usage_reservation_token, prepare_websocket_report_context,
provider_terminal_outcome, release_then_record_responses_websocket_admission_failure,
resolve_responses_websocket_turn_timeouts, responses_websocket_admission_failure_update,
websocket_event_as_sse_line, ResponsesWebSocketTurnDeadline, ResponsesWebSocketTurnOutcome,
ResponsesWebSocketTurnTimeoutPhase,
};
use crate::execution_runtime::attempt_lifecycle::{
classify_attempt_settlement, AttemptBilling, AttemptCandidateError, AttemptCandidateStatus,
AttemptClientDelivery, AttemptProviderEffect, AttemptSettlementInputs,
};
use crate::GatewayError;
#[test]
fn server_plan_usage_reservation_token_overrides_seed_value() {
let context = attach_plan_usage_reservation_token(
Some(json!({
"candidate_index": 2,
"plan_usage_reservation_token": "client-controlled-token"
})),
"server-reservation-token",
)
.expect("reservation context");
assert_eq!(context["candidate_index"], 2);
assert_eq!(
context["plan_usage_reservation_token"],
"server-reservation-token"
);
assert_eq!(context["plan_usage_reservation_deferred"], false);
}
#[test]
fn gateway_can_defer_a_server_owned_reservation_after_transport_handoff() {
let context = attach_plan_usage_reservation_deferred(Some(json!({
"plan_usage_reservation_token": "server-reservation-token",
"plan_usage_reservation_deferred": false
})))
.expect("reservation context");
assert_eq!(context["plan_usage_reservation_deferred"], true);
}
#[tokio::test]
async fn admission_failure_releases_pool_lease_before_recording_candidate_terminal() {
let phase = Arc::new(AtomicU8::new(0));
let release_phase = Arc::clone(&phase);
let record_phase = Arc::clone(&phase);
release_then_record_responses_websocket_admission_failure(
async move {
assert_eq!(release_phase.swap(1, Ordering::SeqCst), 0);
},
async move {
assert_eq!(record_phase.swap(2, Ordering::SeqCst), 1);
},
)
.await;
assert_eq!(phase.load(Ordering::SeqCst), 2);
}
#[test]
fn admission_timeout_terminalizes_the_seeded_candidate() {
let update = responses_websocket_admission_failure_update(
1_000,
1_025,
&GatewayError::AdmissionTimeout {
trace_id: "turn-1".to_string(),
gate: "gateway_upstream_execution",
queue_budget_ms: 25,
},
);
assert_eq!(update.status, RequestCandidateStatus::Failed);
assert_eq!(update.status_code, Some(429));
assert_eq!(
update.error_type.as_deref(),
Some("gateway_admission_timeout")
);
assert_eq!(
update.error_message.as_deref(),
Some("gateway admission gate gateway_upstream_execution timed out after 25ms")
);
assert_eq!(update.latency_ms, Some(25));
assert_eq!(update.started_at_unix_ms, Some(1_000));
assert_eq!(update.finished_at_unix_ms, Some(1_025));
}
#[test]
fn non_timeout_admission_failure_still_terminalizes_the_seeded_candidate() {
let update = responses_websocket_admission_failure_update(
50,
40,
&GatewayError::Internal("admission gate closed".to_string()),
);
assert_eq!(update.status, RequestCandidateStatus::Failed);
assert_eq!(update.status_code, Some(500));
assert_eq!(
update.error_type.as_deref(),
Some("gateway_admission_failed")
);
assert_eq!(update.latency_ms, Some(0));
assert_eq!(update.started_at_unix_ms, Some(50));
assert_eq!(update.finished_at_unix_ms, Some(40));
}
#[test]
fn followup_context_uses_a_fresh_request_and_candidate() {
let context = prepare_websocket_report_context(
Some(json!({
"request_id":"connection",
"candidate_id":"candidate",
"candidate_index": 0,
"pool_key_lease_key": "lease",
"original_request_body":{"model":"public"}
})),
"turn-2",
false,
&json!({"type":"response.create","model":"public"}),
&json!({"type":"response.create","model":"provider-public"}),
"connection",
2,
"logical-turn-2",
1,
);
assert_eq!(context["request_id"], "turn-2");
assert!(context.get("candidate_id").is_none());
assert!(context.get("candidate_index").is_none());
assert!(context.get("pool_key_lease_key").is_none());
assert_eq!(context["original_request_body"]["type"], "response.create");
assert_eq!(context["model"], "public");
assert_eq!(context["mapped_model"], "provider-public");
assert_eq!(context["websocket_mode"], true);
assert_eq!(context["websocket_transport"], "responses");
assert_eq!(context["websocket_logical_turn_id"], "logical-turn-2");
assert_eq!(context["websocket_turn_attempt"], 1);
}
#[test]
fn replanned_context_keeps_selected_candidate_and_records_the_new_client_model() {
let context = prepare_websocket_report_context(
Some(json!({
"request_id": "prewarm",
"candidate_id": "terra-candidate",
"original_request_body": {"model": "gpt-5.6-sol", "generate": false}
})),
"turn-2",
true,
&json!({
"type": "response.create",
"model": "gpt-5.6-terra",
"input": "hello"
}),
&json!({
"type": "response.create",
"model": "gpt-5.6-terra-provider",
"input": "hello"
}),
"connection",
2,
"logical-turn-2",
2,
);
assert_eq!(context["request_id"], "turn-2");
assert_eq!(context["candidate_id"], "terra-candidate");
assert_eq!(context["original_request_body"]["model"], "gpt-5.6-terra");
assert_eq!(context["model"], "gpt-5.6-terra");
assert_eq!(context["mapped_model"], "gpt-5.6-terra-provider");
assert_eq!(context["websocket_mode"], true);
assert_eq!(context["websocket_logical_turn_id"], "logical-turn-2");
assert_eq!(context["websocket_turn_attempt"], 2);
}
#[test]
fn completed_event_is_captured_as_a_responses_sse_terminal_event() {
let event = json!({
"type": "response.completed",
"response": {
"id": "resp_ws_usage_123",
"model": "gpt-5.6",
"usage": {
"input_tokens": 3,
"output_tokens": 5,
"total_tokens": 8
}
}
});
let raw = serde_json::to_string(&event).expect("event should serialize");
let frame = ParsedResponsesWebSocketFrame::parse(&raw).expect("event should parse");
let outcome = provider_terminal_outcome(&frame);
assert_eq!(
outcome,
Some(ResponsesWebSocketTurnOutcome::ProviderTerminal {
status_code: 200,
cancelled: false
})
);
let capture = String::from_utf8(websocket_event_as_sse_line(&event))
.expect("capture should be UTF-8");
assert_eq!(capture, format!("data: {event}\n\n"));
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&report_context, &[&event]);
let summary = observer.finish(&report_context);
let usage = summary
.standardized_usage
.expect("response.completed usage must reach the terminal summary");
assert_eq!(usage.input_tokens, 3);
assert_eq!(usage.output_tokens, 5);
assert_eq!(usage.dimensions.get("total_tokens"), Some(&json!(8)));
}
#[test]
fn a_legitimate_incomplete_is_a_successful_provider_terminal_that_keeps_its_usage() {
// 写满 max_output_tokens 的 incomplete 是合法终态:状态码不再是 502,
// usage 观测器照样能看到 finish 和 token,记账层不该把它当解析失败。
let event = json!({
"type": "response.incomplete",
"response": {
"id": "resp_ws_incomplete_123",
"model": "gpt-5.6",
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"},
"output": [],
"usage": {
"input_tokens": 4,
"output_tokens": 7,
"total_tokens": 11
}
}
});
let raw = serde_json::to_string(&event).expect("event should serialize");
let frame = ParsedResponsesWebSocketFrame::parse(&raw).expect("event should parse");
let outcome = provider_terminal_outcome(&frame);
assert_eq!(
outcome,
Some(ResponsesWebSocketTurnOutcome::ProviderTerminal {
status_code: 200,
cancelled: false
})
);
let outcome = outcome.expect("incomplete should end the turn");
let facts = attempt_facts_for_outcome(None, AttemptClientDelivery::Complete, outcome);
assert!(!facts.provider.cancelled_by_provider());
assert!(facts.forced_error().is_none());
assert!(!facts.provider.stream_timeout());
assert!(facts.provider.is_terminal());
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&report_context, &[&event]);
let summary = observer.finish(&report_context);
assert!(summary.observed_finish);
assert_eq!(summary.finish_reason.as_deref(), Some("length"));
assert!(summary.parser_error.is_none());
let usage = summary
.standardized_usage
.expect("incomplete usage must reach the terminal summary");
assert_eq!(usage.input_tokens, 4);
assert_eq!(usage.output_tokens, 7);
// 结算表用这些事实决定是否投射供应商失败:合法 incomplete 即使被记账层
// 判成失败,也必须落在「不扣健康分、只释放 lease」一侧,且账单照记。
let settlement = classify_attempt_settlement(AttemptSettlementInputs {
facts,
report_represents_failure: true,
observed_finish: summary.observed_finish,
has_parser_error: summary.parser_error.is_some(),
});
assert_eq!(settlement.status_code, 200);
assert_eq!(settlement.billing, AttemptBilling::Billed);
assert_eq!(
settlement.provider_effect,
AttemptProviderEffect::ReleasePoolKeyLease
);
assert!(settlement.submit_execution_report);
}
#[test]
fn an_incomplete_without_a_legitimate_reason_still_projects_a_provider_failure() {
let raw = r#"{"type":"response.incomplete","response":{"incomplete_details":{"reason":"error"}}}"#;
let frame = ParsedResponsesWebSocketFrame::parse(raw).expect("event should parse");
assert_eq!(
provider_terminal_outcome(&frame),
Some(ResponsesWebSocketTurnOutcome::ProviderTerminal {
status_code: 502,
cancelled: false
})
);
}
/// relay 级:provider 终态帧已经到达,但客户端 socket 已经关闭。
///
/// 走的是 relay loop 写客户端失败时的完整决策链——真实帧解析 →
/// 记录 provider 事实 → 记录投递失败 → 选结算信号 → 结算表。旧实现在这里
/// 用 client_disconnected() 覆盖 outcome,于是一条已经产出 token 的响应被
/// 记成 void billing、不投射供应商效果、也不提交 execution report。
#[test]
fn a_provider_terminal_that_reaches_a_closed_client_socket_is_still_billed() {
let completed = json!({
"type": "response.completed",
"response": {
"id": "resp_ws_delivery_failed",
"model": "gpt-5.6",
"usage": {"input_tokens": 3, "output_tokens": 5, "total_tokens": 8}
}
});
let raw = serde_json::to_string(&completed).expect("event should serialize");
let frame = ParsedResponsesWebSocketFrame::parse(&raw).expect("event should parse");
// relay loop 观察到终态帧:attempt 记下 provider 事实。
let observed = provider_terminal_outcome(&frame).expect("completed ends the turn");
let recorded_provider =
attempt_facts_for_outcome(None, AttemptClientDelivery::Complete, observed).provider;
// 随后写客户端失败:记录投递失败,并按 provider 终态选结算信号。
let delivery = AttemptClientDelivery::Aborted {
reason: "gateway could not relay the provider event to the client",
};
let signal = settle_signal_for_client_delivery_failure(Some(observed));
assert_eq!(
signal, observed,
"a reached terminal must remain the signal"
);
let facts = attempt_facts_for_outcome(Some(recorded_provider), delivery, signal);
// 终态摘要保留真实 usage 与 finish_reason,不被改写成 cancelled。
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&report_context, &[&completed]);
let summary = observer.finish(&report_context);
assert!(summary.observed_finish);
assert!(summary.parser_error.is_none());
let usage = summary
.standardized_usage
.clone()
.expect("usage must survive a delivery failure");
assert_eq!(usage.input_tokens, 3);
assert_eq!(usage.output_tokens, 5);
let settlement = classify_attempt_settlement(AttemptSettlementInputs {
facts,
report_represents_failure: false,
observed_finish: summary.observed_finish,
has_parser_error: summary.parser_error.is_some(),
});
assert_eq!(settlement.billing, AttemptBilling::Billed);
assert_eq!(settlement.status_code, 200);
assert_eq!(settlement.candidate_status, AttemptCandidateStatus::Success);
assert_eq!(
settlement.provider_effect,
AttemptProviderEffect::ProviderSuccess
);
assert!(settlement.submit_execution_report);
// 投递失败仍然留痕。
assert_eq!(
settlement.candidate_error,
AttemptCandidateError::ClientDeliveryFailed
);
}
/// 同一条链路,但供应商还没给出终态:仍然作废账单、不提交 report。
#[test]
fn a_closed_client_socket_before_any_terminal_still_voids_the_bill() {
let signal = settle_signal_for_client_delivery_failure(None);
let facts = attempt_facts_for_outcome(
None,
AttemptClientDelivery::Aborted {
reason: "gateway could not relay the provider event to the client",
},
signal,
);
let settlement = classify_attempt_settlement(AttemptSettlementInputs {
facts,
report_represents_failure: false,
observed_finish: false,
has_parser_error: false,
});
assert_eq!(settlement.billing, AttemptBilling::Void);
assert_eq!(settlement.status_code, 499);
assert_eq!(
settlement.candidate_status,
AttemptCandidateStatus::Cancelled
);
assert_eq!(
settlement.provider_effect,
AttemptProviderEffect::ReleasePoolKeyLease
);
assert!(!settlement.submit_execution_report);
}
/// 投递失败只往 report context 里加字段,不改既有字段。
#[test]
fn the_client_delivery_marker_only_adds_report_context_fields() {
let context = attach_client_delivery_to_report_context(
Some(json!({
"request_id": "turn-2",
"websocket_mode": true,
"original_request_body": {"model": "public"},
})),
"gateway could not relay the provider event to the client",
)
.expect("marker should produce a report context");
assert_eq!(context["websocket_client_delivery"], "aborted");
assert_eq!(
context["websocket_client_delivery_reason"],
"gateway could not relay the provider event to the client"
);
assert_eq!(context["request_id"], "turn-2");
assert_eq!(context["websocket_mode"], true);
assert_eq!(context["original_request_body"]["model"], "public");
}
#[test]
fn error_event_uses_the_top_level_status_code() {
let event = json!({
"type": "error",
"status_code": 429,
"error": {"type": "usage_limit_reached"},
});
let raw = serde_json::to_string(&event).expect("event should serialize");
let frame = ParsedResponsesWebSocketFrame::parse(&raw).expect("event should parse");
assert_eq!(
provider_terminal_outcome(&frame),
Some(ResponsesWebSocketTurnOutcome::ProviderTerminal {
status_code: 429,
cancelled: false,
})
);
}
#[test]
fn quota_close_fallback_preserves_the_client_visible_status() {
let outcome = ResponsesWebSocketTurnOutcome::provider_quota_exhausted();
assert_eq!(outcome.status_code(), 429);
assert!(matches!(
outcome,
ResponsesWebSocketTurnOutcome::Failure {
status_code: 429,
..
}
));
}
#[test]
fn an_abandoned_turn_after_upstream_send_is_recorded_as_a_gateway_failure() {
let outcome = ResponsesWebSocketTurnOutcome::relay_task_abandonment(true);
let facts = attempt_facts_for_outcome(None, AttemptClientDelivery::Complete, outcome);
let settlement = classify_attempt_settlement(AttemptSettlementInputs {
facts,
report_represents_failure: true,
observed_finish: false,
has_parser_error: false,
});
assert_eq!(settlement.status_code, 500);
assert_eq!(settlement.billing, AttemptBilling::Billed);
assert!(!facts.provider.cancelled_by_provider());
assert!(facts.forced_error().is_some());
assert!(settlement.submit_execution_report);
}
#[test]
fn an_abandoned_turn_before_upstream_send_is_void_and_does_not_penalize_provider() {
let outcome = ResponsesWebSocketTurnOutcome::relay_task_abandonment(false);
let facts = attempt_facts_for_outcome(None, AttemptClientDelivery::Complete, outcome);
let settlement = classify_attempt_settlement(AttemptSettlementInputs {
facts,
report_represents_failure: false,
observed_finish: false,
has_parser_error: false,
});
assert_eq!(settlement.status_code, 499);
assert_eq!(settlement.billing, AttemptBilling::Void);
assert_eq!(
settlement.candidate_status,
AttemptCandidateStatus::Cancelled
);
assert_eq!(settlement.candidate_error, AttemptCandidateError::Cancelled);
assert_eq!(
settlement.provider_effect,
AttemptProviderEffect::ReleasePoolKeyLease
);
assert!(!settlement.submit_execution_report);
}
#[test]
fn turn_timeouts_reuse_provider_first_byte_and_request_deadlines() {
let (first_event, terminal) =
resolve_responses_websocket_turn_timeouts(Some(&ExecutionTimeouts {
first_byte_ms: Some(12_345),
total_ms: Some(67_890),
..ExecutionTimeouts::default()
}));
assert_eq!(first_event, Duration::from_millis(12_345));
assert_eq!(terminal, Duration::from_millis(67_890));
}
#[test]
fn first_event_deadline_never_outlives_the_turn_deadline() {
let started_at = Instant::now();
let first_event = Duration::from_secs(30);
let terminal = Duration::from_secs(10);
let deadline = ResponsesWebSocketTurnDeadline {
phase: ResponsesWebSocketTurnTimeoutPhase::AwaitingFirstEvent,
deadline: started_at + first_event.min(terminal),
timeout: first_event.min(terminal),
};
assert_eq!(
deadline.phase,
ResponsesWebSocketTurnTimeoutPhase::AwaitingFirstEvent
);
assert_eq!(deadline.timeout, Duration::from_secs(10));
assert_eq!(deadline.deadline, started_at + Duration::from_secs(10));
}
}