mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
1068 lines
47 KiB
Rust
1068 lines
47 KiB
Rust
//! Connection-level Responses WebSocket FSM.
|
|
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::ws::{Message as AxumWsMessage, WebSocket};
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use serde_json::Value;
|
|
use wreq::ws::message::Message as WreqWsMessage;
|
|
|
|
use super::adapter::ResponsesWebSocketRelayDirective;
|
|
use super::client::{adapter_drain_ready, forward_client_message, RelayDisposition};
|
|
use super::continuation::{
|
|
ResponsesWebSocketContinuationRecord, ResponsesWebSocketContinuationRegistry,
|
|
};
|
|
use super::frame::{encode_opaque_websocket_event, ParsedResponsesWebSocketFrame};
|
|
use super::lifecycle::{
|
|
await_pending_adapter_observation, finalize_active_turn, queue_turn_finalization,
|
|
settle_turn_finalization, spawn_bounded_adapter_observation, PreviousAttemptSettled,
|
|
};
|
|
use super::plan_admission::terminate_responses_websocket_for_plan_permit_loss;
|
|
use super::quota::{
|
|
detach_exhausted_upstream, is_usage_limit_error_event, mark_active_response_retry_unsafe,
|
|
observe_active_response_rebind_safety, retry_active_turn_after_quota_exhaustion,
|
|
};
|
|
use super::relay_policy::{
|
|
classify_quota_relay, fatal_relay_policy, FatalRelaySignal, QuotaRelayAction, QuotaRelayFacts,
|
|
};
|
|
use super::settlement::settle_signal_for_client_delivery_failure;
|
|
use super::state::BoundResponsesConnection;
|
|
use super::turn::{
|
|
ResponsesProviderAttempt, ResponsesWebSocketTurnObservation, ResponsesWebSocketTurnOutcome,
|
|
};
|
|
use super::turn_state::LogicalTurn;
|
|
use super::upstream::{close_bound_upstream, receive_optional_upstream};
|
|
use crate::handlers::proxy::websocket::ingress::WebSocketRequestContext;
|
|
use crate::handlers::proxy::websocket::session::{
|
|
wait_for_optional_deadline, CLOSE_INTERNAL_ERROR, CLOSE_TRY_AGAIN, WEBSOCKET_LOG_TRANSPORT,
|
|
};
|
|
use crate::handlers::proxy::websocket::transport::{
|
|
close_client_socket, send_client_message, send_gateway_error_with_status,
|
|
send_responses_websocket_error, upstream_message_to_client,
|
|
};
|
|
use crate::AppState;
|
|
|
|
const LOG_TARGET: &str = "aether_gateway::handlers::proxy::responses_ws";
|
|
const CONTINUATION_REGISTRATION_TIMEOUT: Duration = Duration::from_millis(500);
|
|
|
|
/// 写客户端 socket 失败时记录的投递失败原因。刻意不说「客户端在终态前断开」:
|
|
/// 供应商的终态可能已经到达,只是最后一跳没送出去。
|
|
const CLIENT_DELIVERY_FAILED_REASON: &str =
|
|
"gateway could not relay the provider event to the client";
|
|
|
|
macro_rules! debug {
|
|
($($arg:tt)*) => {
|
|
tracing::debug!(target: LOG_TARGET, $($arg)*)
|
|
};
|
|
}
|
|
|
|
macro_rules! warn {
|
|
($($arg:tt)*) => {
|
|
tracing::warn!(target: LOG_TARGET, $($arg)*)
|
|
};
|
|
}
|
|
|
|
pub(super) async fn relay_bound_connection(
|
|
client_socket: &mut WebSocket,
|
|
bound: &mut BoundResponsesConnection,
|
|
state: &AppState,
|
|
context: &WebSocketRequestContext,
|
|
) {
|
|
let mut client_connected = true;
|
|
loop {
|
|
if !client_connected && !bound.turn_state.response_in_flight() {
|
|
close_bound_upstream(bound).await;
|
|
break;
|
|
}
|
|
let active_turn_deadline = bound.turn_state.attempt().map(|turn| turn.deadline());
|
|
tokio::select! {
|
|
_ = wait_for_optional_deadline(active_turn_deadline.map(|deadline| deadline.deadline)) => {
|
|
let Some(turn_deadline) = active_turn_deadline else {
|
|
continue;
|
|
};
|
|
warn!(
|
|
event_name = "responses_websocket_turn_timeout",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
timeout_phase = ?turn_deadline.phase,
|
|
timeout_ms = turn_deadline.timeout.as_millis() as u64,
|
|
"Responses WebSocket response did not reach its configured deadline"
|
|
);
|
|
finalize_active_turn(bound, state, turn_deadline.phase.outcome()).await;
|
|
send_gateway_error_with_status(
|
|
client_socket,
|
|
504,
|
|
turn_deadline.phase.error_code(),
|
|
turn_deadline.phase.client_message(),
|
|
).await;
|
|
close_bound_upstream(bound).await;
|
|
close_client_socket(
|
|
client_socket,
|
|
CLOSE_TRY_AGAIN,
|
|
turn_deadline.phase.error_code(),
|
|
).await;
|
|
break;
|
|
}
|
|
client_message = client_socket.next(), if client_connected => {
|
|
let Some(client_message) = client_message else {
|
|
if retain_disconnected_turn(bound) {
|
|
client_connected = false;
|
|
continue;
|
|
}
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::client_disconnected(),
|
|
).await;
|
|
close_bound_upstream(bound).await;
|
|
break;
|
|
};
|
|
let Ok(client_message) = client_message else {
|
|
if retain_disconnected_turn(bound) {
|
|
client_connected = false;
|
|
continue;
|
|
}
|
|
warn!(
|
|
event_name = "responses_websocket_client_receive_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
"client WebSocket receive failed"
|
|
);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::client_disconnected(),
|
|
).await;
|
|
close_bound_upstream(bound).await;
|
|
break;
|
|
};
|
|
if matches!(client_message, AxumWsMessage::Close(_))
|
|
&& retain_disconnected_turn(bound)
|
|
{
|
|
client_connected = false;
|
|
continue;
|
|
}
|
|
match Box::pin(forward_client_message(
|
|
client_message,
|
|
bound,
|
|
client_socket,
|
|
state,
|
|
context,
|
|
))
|
|
.await
|
|
{
|
|
RelayDisposition::Continue => {}
|
|
RelayDisposition::Close => {
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::client_disconnected(),
|
|
).await;
|
|
break;
|
|
}
|
|
RelayDisposition::PlanUsagePermitLost => {
|
|
warn!(
|
|
event_name = "responses_websocket_plan_usage_concurrency_lost_before_send",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
"gateway stopped a Responses WebSocket turn before its upstream send after the subscription plan concurrency lease became unhealthy"
|
|
);
|
|
close_bound_upstream(bound).await;
|
|
terminate_responses_websocket_for_plan_permit_loss(client_socket).await;
|
|
break;
|
|
}
|
|
RelayDisposition::UpstreamError(code) => {
|
|
warn!(
|
|
event_name = "responses_websocket_upstream_send_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
error_code = code,
|
|
"Upstream WebSocket send failed"
|
|
);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::upstream_send_failed(),
|
|
).await;
|
|
send_gateway_error_with_status(
|
|
client_socket,
|
|
502,
|
|
code,
|
|
"Gateway could not forward the WebSocket event upstream",
|
|
).await;
|
|
close_bound_upstream(bound).await;
|
|
close_client_socket(client_socket, CLOSE_INTERNAL_ERROR, code).await;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
upstream_message = receive_optional_upstream(&mut bound.upstream) => {
|
|
let Some(upstream_message) = upstream_message else {
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::upstream_closed(),
|
|
).await;
|
|
bound.upstream = None;
|
|
close_client_socket(client_socket, 1000, "upstream_closed").await;
|
|
break;
|
|
};
|
|
let Ok(upstream_message) = upstream_message else {
|
|
warn!(
|
|
event_name = "responses_websocket_upstream_receive_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
"Upstream WebSocket receive failed"
|
|
);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::upstream_receive_failed(),
|
|
).await;
|
|
send_gateway_error_with_status(
|
|
client_socket,
|
|
502,
|
|
"responses_websocket_receive_failed",
|
|
"Provider connection closed unexpectedly",
|
|
).await;
|
|
bound.upstream = None;
|
|
close_client_socket(client_socket, CLOSE_INTERNAL_ERROR, "upstream_receive_failed").await;
|
|
break;
|
|
};
|
|
let parsed_upstream_frame = match &upstream_message {
|
|
WreqWsMessage::Text(text) => {
|
|
ParsedResponsesWebSocketFrame::parse(text.as_str()).ok()
|
|
}
|
|
_ => None,
|
|
};
|
|
if parsed_upstream_frame
|
|
.as_ref()
|
|
.is_some_and(ParsedResponsesWebSocketFrame::carries_stream_id)
|
|
{
|
|
// The session currently owns only the implicit default
|
|
// lane. Reject a provider-side named identity before the
|
|
// adapter, usage observer, continuation cache, PII
|
|
// restorer, or logical-turn state can attribute an
|
|
// interleaved event to the sole default-lane attempt.
|
|
let policy = fatal_relay_policy(
|
|
FatalRelaySignal::UnexpectedUpstreamStreamId,
|
|
);
|
|
let frame = parsed_upstream_frame
|
|
.as_ref()
|
|
.expect("the stream-id guard requires a parsed frame");
|
|
warn!(
|
|
event_name = "responses_websocket_unexpected_upstream_stream_id",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
event_type = %frame.event_type_for_log(),
|
|
frame_bytes = frame.raw_text().len(),
|
|
chunked = frame.is_chunked(),
|
|
"gateway rejected a named-lane provider event on a default-lane Responses WebSocket"
|
|
);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::upstream_receive_failed(),
|
|
)
|
|
.await;
|
|
send_responses_websocket_error(
|
|
client_socket,
|
|
policy.status_code,
|
|
"server_error",
|
|
policy.error_code,
|
|
policy.client_message,
|
|
)
|
|
.await;
|
|
close_bound_upstream(bound).await;
|
|
close_client_socket(
|
|
client_socket,
|
|
policy.close_code,
|
|
policy.close_reason,
|
|
)
|
|
.await;
|
|
break;
|
|
}
|
|
let parsed_upstream_event = parsed_upstream_frame
|
|
.as_ref()
|
|
.map(ParsedResponsesWebSocketFrame::event);
|
|
if let WreqWsMessage::Text(text) = &upstream_message {
|
|
debug!(
|
|
event_name = "responses_websocket_upstream_event",
|
|
log_type = "event",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
event_type = %parsed_upstream_frame
|
|
.as_ref()
|
|
.map(ParsedResponsesWebSocketFrame::event_type_for_log)
|
|
.unwrap_or_else(|| "invalid_json".to_string()),
|
|
frame_bytes = text.len(),
|
|
chunked = parsed_upstream_frame
|
|
.as_ref()
|
|
.is_some_and(ParsedResponsesWebSocketFrame::is_chunked),
|
|
active_turn = bound.turn_state.response_in_flight(),
|
|
"gateway received Responses WebSocket event"
|
|
);
|
|
}
|
|
if matches!(&upstream_message, WreqWsMessage::Binary(_)) {
|
|
mark_active_response_retry_unsafe(bound, "upstream_binary_frame");
|
|
} else if matches!(&upstream_message, WreqWsMessage::Text(_))
|
|
&& parsed_upstream_event.is_none()
|
|
{
|
|
mark_active_response_retry_unsafe(bound, "invalid_upstream_event");
|
|
}
|
|
if let Some(event) = parsed_upstream_event {
|
|
observe_active_response_rebind_safety(bound, event);
|
|
if bound.pending_adapter_drain.is_none()
|
|
&& bound.adapter.observes_upstream_events()
|
|
{
|
|
let adapter = bound.adapter;
|
|
if let Some(observation) = adapter.observe_upstream_event(event) {
|
|
let directive = observation.drain;
|
|
await_pending_adapter_observation(bound).await;
|
|
let state_for_observation = state.clone();
|
|
let trace_id = context.trace_id.clone();
|
|
let report_context = bound.decision_template.report_context.clone();
|
|
bound.pending_adapter_observation = Some(spawn_bounded_adapter_observation(async move {
|
|
adapter
|
|
.persist_upstream_observation(
|
|
&state_for_observation,
|
|
&trace_id,
|
|
report_context.as_ref(),
|
|
observation,
|
|
)
|
|
.await;
|
|
}));
|
|
if let Some(directive) = directive {
|
|
bound.pending_adapter_drain = Some(directive);
|
|
// A definitive quota signal must be visible to
|
|
// the next planner before a transparent retry.
|
|
await_pending_adapter_observation(bound).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let observation = match &upstream_message {
|
|
WreqWsMessage::Text(text) => {
|
|
let adapter = bound.adapter;
|
|
match parsed_upstream_frame.as_ref() {
|
|
Some(frame) => bound
|
|
.turn_state
|
|
.attempt_mut()
|
|
.and_then(|turn| turn.observe_upstream_frame(frame, adapter)),
|
|
None => {
|
|
if let Some(turn) = bound.turn_state.attempt_mut() {
|
|
turn.observe_invalid_upstream_text(text.as_str())
|
|
}
|
|
else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => None,
|
|
};
|
|
if matches!(
|
|
observation,
|
|
Some(ResponsesWebSocketTurnObservation::Started)
|
|
| Some(ResponsesWebSocketTurnObservation::Terminal(_))
|
|
) {
|
|
if let Some(turn) = bound.turn_state.attempt_mut() {
|
|
turn.mark_stream_started(state).await;
|
|
}
|
|
}
|
|
let terminal_outcome = match observation {
|
|
Some(ResponsesWebSocketTurnObservation::Terminal(outcome)) => Some(outcome),
|
|
_ => None,
|
|
};
|
|
if let Some(frame) = parsed_upstream_frame.as_ref() {
|
|
if let Some(response_id) = evicted_default_lane_continuation_response_id(
|
|
bound.turn_state.logical(),
|
|
frame,
|
|
) {
|
|
// A 4xx/5xx continuation terminal evicts the referenced
|
|
// ID from the provider's implicit default-lane cache.
|
|
// Do not keep claiming local ownership and replay it.
|
|
bound
|
|
.continuation_response_ids
|
|
.forget_connection_local(response_id);
|
|
}
|
|
if let Some(response_id) = connection_local_terminal_response_id(
|
|
bound.turn_state.logical(),
|
|
frame,
|
|
) {
|
|
// Remember every successful response on this physical
|
|
// socket, including store=false responses that exist
|
|
// only in the provider's connection-local cache.
|
|
bound
|
|
.continuation_response_ids
|
|
.remember_connection_local(response_id);
|
|
}
|
|
if let Some(registration) =
|
|
prepare_persisted_continuation_registration(bound, context, frame)
|
|
{
|
|
if let Some(response_id) =
|
|
register_persisted_continuation_before_terminal_delivery(
|
|
state,
|
|
context,
|
|
registration,
|
|
)
|
|
.await
|
|
{
|
|
bound
|
|
.continuation_response_ids
|
|
.remember_persisted(response_id.as_str());
|
|
}
|
|
}
|
|
}
|
|
if matches!(&upstream_message, WreqWsMessage::Text(_))
|
|
&& parsed_upstream_frame.is_none()
|
|
{
|
|
let policy = fatal_relay_policy(FatalRelaySignal::InvalidUpstreamText);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
terminal_outcome.unwrap_or_else(
|
|
ResponsesWebSocketTurnOutcome::upstream_receive_failed,
|
|
),
|
|
)
|
|
.await;
|
|
send_responses_websocket_error(
|
|
client_socket,
|
|
policy.status_code,
|
|
"server_error",
|
|
policy.error_code,
|
|
policy.client_message,
|
|
)
|
|
.await;
|
|
close_bound_upstream(bound).await;
|
|
close_client_socket(
|
|
client_socket,
|
|
policy.close_code,
|
|
policy.close_reason,
|
|
)
|
|
.await;
|
|
break;
|
|
}
|
|
let is_close = matches!(upstream_message, WreqWsMessage::Close(_));
|
|
let drain_for_adapter = adapter_drain_ready(
|
|
bound.pending_adapter_drain,
|
|
bound.turn_state.response_in_flight(),
|
|
observation,
|
|
is_close,
|
|
);
|
|
let quota_facts = QuotaRelayFacts {
|
|
drain_ready: drain_for_adapter,
|
|
retry_current_turn: bound
|
|
.pending_adapter_drain
|
|
.is_some_and(|directive| directive.retry_current_turn)
|
|
&& bound
|
|
.turn_state
|
|
.logical()
|
|
.is_some_and(|turn| turn.quota_retry_block_reason().is_none()),
|
|
transparent_retry_failed: false,
|
|
usage_limit_error: parsed_upstream_event.is_some_and(is_usage_limit_error_event),
|
|
upstream_closed: is_close,
|
|
};
|
|
let mut quota_relay_action = classify_quota_relay(quota_facts);
|
|
if matches!(quota_relay_action, QuotaRelayAction::AttemptTransparentRetry) {
|
|
// detach_attempt 保留 logical turn:重试是同一轮请求的下一个 attempt。
|
|
let retry_turn = bound.turn_state.detach_attempt();
|
|
// 先结算旧 attempt 并等它落地,再规划下一个 attempt。两个理由:
|
|
//
|
|
// 1. 规划要读 health / adaptive / pool 状态,而这些正是旧
|
|
// attempt 结算时才投射的。普通的新 turn 早就在 client.rs 里
|
|
// 用 await_pending_turn_finalization 挡住了「基于陈旧状态
|
|
// 规划」,透明重试这条路径原先漏了这一步。
|
|
// 2. 旧 attempt 还占着自己的 pool key lease。不先释放,重试就
|
|
// 可能因为「这把 key 仍被占用」而挑不到本该可用的替代 key,
|
|
// 或者干脆判成无可用供应商。
|
|
let settled = match retry_turn {
|
|
Some(mut turn) => {
|
|
turn.release_admission().await;
|
|
settle_turn_finalization(
|
|
bound,
|
|
state,
|
|
turn,
|
|
terminal_outcome.unwrap_or_else(
|
|
ResponsesWebSocketTurnOutcome::upstream_closed,
|
|
),
|
|
)
|
|
.await
|
|
}
|
|
None => Some(PreviousAttemptSettled::nothing_to_settle()),
|
|
};
|
|
// Planning and binding a replacement carries the complete
|
|
// scheduler/provider state machine. Keep that large future
|
|
// off the relay task's stack; the default Tokio/test worker
|
|
// stack is otherwise easy to exhaust on this rare branch.
|
|
if let Some(settled) = settled {
|
|
match Box::pin(retry_active_turn_after_quota_exhaustion(
|
|
bound, state, context, settled,
|
|
))
|
|
.await
|
|
{
|
|
Ok(true) => continue,
|
|
Ok(false) => {}
|
|
Err(()) => {
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::connection_admission_lost(),
|
|
)
|
|
.await;
|
|
close_bound_upstream(bound).await;
|
|
terminate_responses_websocket_for_plan_permit_loss(client_socket)
|
|
.await;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// 重试失败。旧 attempt 已经结算,logical turn 仍停在
|
|
// Replanning,所以后面分支里的 end() / finalize_active_turn
|
|
// 只会清掉 logical turn 而不会交出 attempt——不存在重复结算。
|
|
quota_relay_action = classify_quota_relay(QuotaRelayFacts {
|
|
retry_current_turn: false,
|
|
transparent_retry_failed: true,
|
|
..quota_facts
|
|
});
|
|
}
|
|
let detach_after_forward =
|
|
matches!(quota_relay_action, QuotaRelayAction::ForwardQuotaAndDetach);
|
|
if detach_after_forward && is_close {
|
|
let directive = bound
|
|
.pending_adapter_drain
|
|
.expect("adapter drain state should be present");
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
terminal_outcome
|
|
.unwrap_or_else(ResponsesWebSocketTurnOutcome::provider_quota_exhausted),
|
|
)
|
|
.await;
|
|
send_gateway_error_with_status(
|
|
client_socket,
|
|
429,
|
|
directive.error_code,
|
|
"Provider connection closed after reporting exhausted quota; send a new response.create to select another Provider connection",
|
|
)
|
|
.await;
|
|
detach_exhausted_upstream(bound, directive, &context.trace_id).await;
|
|
continue;
|
|
}
|
|
// Standard Responses frames cross the gateway byte-for-byte unless PII
|
|
// restoration has something to replace. Codex may wrap public events with
|
|
// provider-private side-channel chunks; only that explicit envelope is
|
|
// peeled, and each retained event is serialized as a complete opaque Value.
|
|
// Observation and capture continue to consume the redacted event, while the
|
|
// final client hop receives restored text.
|
|
let relay_directive = parsed_upstream_frame
|
|
.as_ref()
|
|
.map(|frame| {
|
|
bound
|
|
.adapter
|
|
.relay_directive_for_upstream_event(frame.event())
|
|
});
|
|
let mut relay_send_error = None;
|
|
let mut relay_serialization_failed = false;
|
|
match relay_directive {
|
|
_ if !client_connected => {}
|
|
Some(ResponsesWebSocketRelayDirective::ForwardOriginal) => {
|
|
let client_frame = match parsed_upstream_frame.as_ref().map(|frame| {
|
|
bound
|
|
.redaction_restorer
|
|
.restore_provider_frame_text(frame.event())
|
|
}) {
|
|
Some(Ok(Some(text))) => Some(AxumWsMessage::Text(text.into())),
|
|
Some(Ok(None)) | None => {
|
|
Some(upstream_message_to_client(upstream_message.clone()))
|
|
}
|
|
Some(Err(_)) => {
|
|
relay_serialization_failed = true;
|
|
None
|
|
}
|
|
};
|
|
if let Some(client_frame) = client_frame {
|
|
match send_client_message(client_socket, client_frame).await {
|
|
Ok(()) => {
|
|
if let (Some(turn), Some(frame)) = (
|
|
bound.turn_state.attempt_mut(),
|
|
parsed_upstream_frame.as_ref(),
|
|
) {
|
|
turn.capture_client_frame(frame.event());
|
|
}
|
|
}
|
|
Err(error) => relay_send_error = Some(error),
|
|
}
|
|
}
|
|
}
|
|
Some(ResponsesWebSocketRelayDirective::ForwardEvents(events)) => {
|
|
for event in events {
|
|
let event = event.as_ref();
|
|
let text = match bound
|
|
.redaction_restorer
|
|
.restore_provider_frame_text(event)
|
|
{
|
|
Ok(Some(restored)) => restored,
|
|
Ok(None) => match encode_opaque_websocket_event(event) {
|
|
Ok(encoded) => encoded,
|
|
Err(_) => {
|
|
relay_serialization_failed = true;
|
|
break;
|
|
}
|
|
},
|
|
Err(_) => {
|
|
relay_serialization_failed = true;
|
|
break;
|
|
}
|
|
};
|
|
match send_client_message(
|
|
client_socket,
|
|
AxumWsMessage::Text(text.into()),
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
if let Some(turn) = bound.turn_state.attempt_mut() {
|
|
turn.capture_client_frame(event);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
relay_send_error = Some(error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Some(ResponsesWebSocketRelayDirective::SuppressProviderPrivate) => {}
|
|
None => {
|
|
if let Err(error) = send_client_message(
|
|
client_socket,
|
|
upstream_message_to_client(upstream_message.clone()),
|
|
)
|
|
.await
|
|
{
|
|
relay_send_error = Some(error);
|
|
}
|
|
}
|
|
}
|
|
if relay_serialization_failed {
|
|
warn!(
|
|
event_name = "responses_websocket_provider_event_serialization_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
provider_terminal_reached = terminal_outcome.is_some(),
|
|
"gateway could not serialize an opaque provider event"
|
|
);
|
|
bound
|
|
.turn_state
|
|
.record_client_delivery_aborted(CLIENT_DELIVERY_FAILED_REASON);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
settle_signal_for_client_delivery_failure(terminal_outcome),
|
|
)
|
|
.await;
|
|
send_gateway_error_with_status(
|
|
client_socket,
|
|
502,
|
|
"responses_websocket_event_serialization_failed",
|
|
"Gateway could not relay the provider event",
|
|
)
|
|
.await;
|
|
close_bound_upstream(bound).await;
|
|
close_client_socket(
|
|
client_socket,
|
|
CLOSE_INTERNAL_ERROR,
|
|
"provider_event_serialization_failed",
|
|
)
|
|
.await;
|
|
break;
|
|
}
|
|
if let Some(error) = relay_send_error {
|
|
if terminal_outcome.is_none() && retain_disconnected_turn(bound) {
|
|
client_connected = false;
|
|
continue;
|
|
}
|
|
warn!(
|
|
event_name = "responses_websocket_client_send_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
error_code = error.as_str(),
|
|
provider_terminal_reached = terminal_outcome.is_some(),
|
|
"gateway could not relay a provider event to the client"
|
|
);
|
|
// 投递失败是独立事实,不能覆盖已经到达的 provider 终态:
|
|
// 供应商已经完成推理并消耗 token,账单按它的终态计。
|
|
bound
|
|
.turn_state
|
|
.record_client_delivery_aborted(CLIENT_DELIVERY_FAILED_REASON);
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
settle_signal_for_client_delivery_failure(terminal_outcome),
|
|
).await;
|
|
close_bound_upstream(bound).await;
|
|
break;
|
|
}
|
|
if let Some(outcome) = terminal_outcome {
|
|
finalize_active_turn(bound, state, outcome).await;
|
|
} else if is_close {
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::upstream_closed(),
|
|
)
|
|
.await;
|
|
}
|
|
if detach_after_forward {
|
|
let directive = bound
|
|
.pending_adapter_drain
|
|
.expect("adapter drain state should be present");
|
|
if bound.turn_state.response_in_flight() {
|
|
finalize_active_turn(
|
|
bound,
|
|
state,
|
|
ResponsesWebSocketTurnOutcome::provider_quota_exhausted(),
|
|
)
|
|
.await;
|
|
}
|
|
detach_exhausted_upstream(bound, directive, &context.trace_id).await;
|
|
continue;
|
|
}
|
|
if drain_for_adapter {
|
|
let directive = bound
|
|
.pending_adapter_drain
|
|
.expect("adapter drain state should be present");
|
|
detach_exhausted_upstream(bound, directive, &context.trace_id).await;
|
|
continue;
|
|
}
|
|
if is_close {
|
|
bound.upstream = None;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn retain_disconnected_turn(bound: &mut BoundResponsesConnection) -> bool {
|
|
if bound
|
|
.turn_state
|
|
.attempt()
|
|
.is_none_or(|attempt| attempt.cancel_on_client_disconnect())
|
|
{
|
|
return false;
|
|
}
|
|
bound
|
|
.turn_state
|
|
.record_client_delivery_aborted(CLIENT_DELIVERY_FAILED_REASON);
|
|
true
|
|
}
|
|
|
|
struct PendingContinuationRegistration {
|
|
user_id: String,
|
|
api_key_id: String,
|
|
response_id: String,
|
|
record: ResponsesWebSocketContinuationRecord,
|
|
}
|
|
|
|
fn prepare_persisted_continuation_registration(
|
|
bound: &BoundResponsesConnection,
|
|
context: &WebSocketRequestContext,
|
|
frame: &ParsedResponsesWebSocketFrame<'_>,
|
|
) -> Option<PendingContinuationRegistration> {
|
|
let logical = bound.turn_state.logical();
|
|
let Some(response_id) = persistable_terminal_response_id(logical, frame).map(str::to_string)
|
|
else {
|
|
return None;
|
|
};
|
|
let logical = logical.expect("a persistable terminal requires an active logical turn");
|
|
let Some(auth_context) = logical
|
|
.turn_control
|
|
.as_ref()
|
|
.and_then(|control| control.decision.auth_context.as_ref())
|
|
else {
|
|
warn!(
|
|
event_name = "responses_websocket_continuation_registration_skipped",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
reason = "missing_live_auth_context",
|
|
"gateway did not register a persisted Responses continuation"
|
|
);
|
|
return None;
|
|
};
|
|
let Some(pinned_candidate) =
|
|
crate::ai_serving::ResponsesWebSocketPinnedCandidate::from_decision(
|
|
&bound.decision_template,
|
|
)
|
|
else {
|
|
warn!(
|
|
event_name = "responses_websocket_continuation_registration_skipped",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
reason = "missing_binding_identity",
|
|
"gateway did not register a persisted Responses continuation"
|
|
);
|
|
return None;
|
|
};
|
|
let record = match ResponsesWebSocketContinuationRecord::from_binding(
|
|
pinned_candidate,
|
|
bound.client_model.as_str(),
|
|
bound.provider_model.as_str(),
|
|
&bound.binding_identity,
|
|
&bound.body_normalization,
|
|
bound.redaction_restorer.has_sessions(),
|
|
bound.responses_lite_static_config.clone(),
|
|
) {
|
|
Ok(record) => record,
|
|
Err(error) => {
|
|
warn!(
|
|
event_name = "responses_websocket_continuation_registration_skipped",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
provider_id = %bound.decision_template.provider_id.as_deref().unwrap_or("-"),
|
|
endpoint_id = %bound.decision_template.endpoint_id.as_deref().unwrap_or("-"),
|
|
key_id = %bound.decision_template.key_id.as_deref().unwrap_or("-"),
|
|
reason = error.kind(),
|
|
"gateway could not build a persisted Responses continuation record"
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
Some(PendingContinuationRegistration {
|
|
user_id: auth_context.user_id.clone(),
|
|
api_key_id: auth_context.api_key_id.clone(),
|
|
response_id,
|
|
record,
|
|
})
|
|
}
|
|
|
|
fn persistable_terminal_response_id<'a>(
|
|
logical: Option<&LogicalTurn>,
|
|
frame: &'a ParsedResponsesWebSocketFrame<'_>,
|
|
) -> Option<&'a str> {
|
|
let logical = logical?;
|
|
// `provider_store` is derived from the final framed provider event. False
|
|
// or absent is ZDR/connection-local and must never create a 24-hour KV
|
|
// record, even if the provider happens to return an ID.
|
|
if !logical.provider_store {
|
|
return None;
|
|
}
|
|
connection_local_terminal_response_id(Some(logical), frame)
|
|
}
|
|
|
|
fn connection_local_terminal_response_id<'a>(
|
|
logical: Option<&LogicalTurn>,
|
|
frame: &'a ParsedResponsesWebSocketFrame<'_>,
|
|
) -> Option<&'a str> {
|
|
logical?;
|
|
frame.continuation_response_id()
|
|
}
|
|
|
|
fn evicted_default_lane_continuation_response_id<'a>(
|
|
logical: Option<&'a LogicalTurn>,
|
|
frame: &ParsedResponsesWebSocketFrame<'_>,
|
|
) -> Option<&'a str> {
|
|
let logical = logical?;
|
|
let terminal = frame.terminal()?;
|
|
if terminal.status_code < 400 || terminal.cancelled {
|
|
return None;
|
|
}
|
|
logical
|
|
.client_event
|
|
.get("previous_response_id")
|
|
.and_then(Value::as_str)
|
|
.filter(|response_id| !response_id.trim().is_empty())
|
|
}
|
|
|
|
async fn register_persisted_continuation_before_terminal_delivery(
|
|
state: &AppState,
|
|
context: &WebSocketRequestContext,
|
|
registration: PendingContinuationRegistration,
|
|
) -> Option<String> {
|
|
let PendingContinuationRegistration {
|
|
user_id,
|
|
api_key_id,
|
|
response_id,
|
|
record,
|
|
} = registration;
|
|
let registry = ResponsesWebSocketContinuationRegistry::new(state.runtime_state.as_ref());
|
|
match tokio::time::timeout(
|
|
CONTINUATION_REGISTRATION_TIMEOUT,
|
|
registry.register(
|
|
user_id.as_str(),
|
|
api_key_id.as_str(),
|
|
response_id.as_str(),
|
|
&record,
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(())) => {
|
|
debug!(
|
|
event_name = "responses_websocket_continuation_registered",
|
|
log_type = "event",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
user_id = %user_id,
|
|
api_key_id = %api_key_id,
|
|
provider_id = %record.pinned_candidate().provider_id(),
|
|
endpoint_id = %record.pinned_candidate().endpoint_id(),
|
|
key_id = %record.pinned_candidate().key_id(),
|
|
client_model = %record.client_model(),
|
|
provider_model = %record.provider_model(),
|
|
"gateway registered a persisted Responses continuation before terminal delivery"
|
|
);
|
|
Some(response_id)
|
|
}
|
|
Ok(Err(error)) => {
|
|
warn!(
|
|
event_name = "responses_websocket_continuation_registration_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
provider_id = %record.pinned_candidate().provider_id(),
|
|
endpoint_id = %record.pinned_candidate().endpoint_id(),
|
|
key_id = %record.pinned_candidate().key_id(),
|
|
reason = error.kind(),
|
|
"gateway failed to register a persisted Responses continuation"
|
|
);
|
|
None
|
|
}
|
|
Err(_) => {
|
|
warn!(
|
|
event_name = "responses_websocket_continuation_registration_failed",
|
|
log_type = "ops",
|
|
transport = WEBSOCKET_LOG_TRANSPORT,
|
|
websocket = true,
|
|
trace_id = %context.trace_id,
|
|
provider_id = %record.pinned_candidate().provider_id(),
|
|
endpoint_id = %record.pinned_candidate().endpoint_id(),
|
|
key_id = %record.pinned_candidate().key_id(),
|
|
reason = "timeout",
|
|
timeout_ms = CONTINUATION_REGISTRATION_TIMEOUT.as_millis() as u64,
|
|
"gateway timed out registering a persisted Responses continuation"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn wait_for_connection_permit_loss(
|
|
permit: Option<&aether_runtime::AdmissionPermit>,
|
|
) {
|
|
let Some(permit) = permit else {
|
|
std::future::pending::<()>().await;
|
|
return;
|
|
};
|
|
let mut health = tokio::time::interval(Duration::from_secs(1));
|
|
health.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
loop {
|
|
health.tick().await;
|
|
if !permit.is_healthy() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use super::{
|
|
connection_local_terminal_response_id, evicted_default_lane_continuation_response_id,
|
|
persistable_terminal_response_id, LogicalTurn, ParsedResponsesWebSocketFrame,
|
|
};
|
|
|
|
#[test]
|
|
fn cross_connection_registration_requires_explicit_store_true_and_a_success_terminal() {
|
|
let completed = ParsedResponsesWebSocketFrame::parse(
|
|
r#"{"type":"response.completed","response":{"id":"resp_persisted"}}"#,
|
|
)
|
|
.expect("valid completed event");
|
|
let failed = ParsedResponsesWebSocketFrame::parse(
|
|
r#"{"type":"response.failed","response":{"id":"resp_failed"}}"#,
|
|
)
|
|
.expect("valid failed event");
|
|
|
|
let omitted_or_false = LogicalTurn::new(
|
|
json!({"type": "response.create", "store": false}),
|
|
1,
|
|
"logical-local".to_string(),
|
|
);
|
|
assert_eq!(
|
|
persistable_terminal_response_id(Some(&omitted_or_false), &completed),
|
|
None,
|
|
"store=false or an omitted provider-side store must remain connection-local"
|
|
);
|
|
assert_eq!(persistable_terminal_response_id(None, &completed), None);
|
|
assert_eq!(
|
|
connection_local_terminal_response_id(Some(&omitted_or_false), &completed),
|
|
Some("resp_persisted"),
|
|
"store=false continuations remain valid on the same physical socket"
|
|
);
|
|
assert_eq!(
|
|
connection_local_terminal_response_id(None, &completed),
|
|
None
|
|
);
|
|
|
|
let persisted = LogicalTurn::new(
|
|
json!({"type": "response.create", "store": true}),
|
|
1,
|
|
"logical-persisted".to_string(),
|
|
)
|
|
.with_provider_store(true);
|
|
assert_eq!(
|
|
persistable_terminal_response_id(Some(&persisted), &completed),
|
|
Some("resp_persisted")
|
|
);
|
|
assert_eq!(
|
|
persistable_terminal_response_id(Some(&persisted), &failed),
|
|
None,
|
|
"a failed terminal must never establish cross-connection ownership"
|
|
);
|
|
|
|
let continuation = LogicalTurn::new(
|
|
json!({
|
|
"type": "response.create",
|
|
"previous_response_id": "resp_parent"
|
|
}),
|
|
2,
|
|
"logical-continuation".to_string(),
|
|
);
|
|
assert_eq!(
|
|
evicted_default_lane_continuation_response_id(Some(&continuation), &failed),
|
|
Some("resp_parent")
|
|
);
|
|
assert_eq!(
|
|
evicted_default_lane_continuation_response_id(Some(&continuation), &completed),
|
|
None
|
|
);
|
|
}
|
|
}
|