feat(gateway): Codex/OpenAI Responses WebSocket 代理模式

在 /v1/responses 上支持 WebSocket 升级,把客户端帧中继到上游 Codex /
OpenAI Responses WebSocket 端点,同时保持既有的路由、鉴权、配额与用量
语义:

- 路由与准入:control/route/ai.rs 识别 WebSocket 升级请求;
  websocket/ingress.rs 复用 API Key 鉴权、IP 规则与并发许可,并引入
  独立的 WebSocket 连接许可
- 中继:websocket/responses/* 按 connection / session / turn 分层,
  帧解析归一化、socket 写入有界、continuation 保持调度亲和性
- 配额:orchestration/codex_quota_breaker.rs 在账号配额耗尽时熔断并
  自动恢复,不再直接断开客户端连接
- 用量:每个 turn 的终态用量落库,request_metadata 记录
  websocket_mode / websocket_transport,管理端与 usage 视图暴露
  is_websocket
- 管理端:provider 可配置 Responses WebSocket 开关
This commit is contained in:
AAEE86
2026-08-17 14:50:33 +08:00
committed by ZheFox
parent 9a0d346ff3
commit 71b54070e8
72 changed files with 10441 additions and 108 deletions
@@ -10,7 +10,8 @@ use aether_data_contracts::repository::usage::{
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY,
PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY,
ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY,
ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY,
WEBSOCKET_TRANSPORT_METADATA_KEY,
};
use serde_json::{json, Map, Value};
@@ -348,6 +349,8 @@ fn copy_allowed_metadata_fields(source: &Map<String, Value>, target: &mut Map<St
copy_bool(source, target, UPSTREAM_IS_STREAM_KEY);
copy_non_null_value(source, target, "client_session_affinity");
copy_bool(source, target, "api_key_is_standalone");
copy_bool(source, target, WEBSOCKET_MODE_METADATA_KEY);
copy_non_empty_string(source, target, WEBSOCKET_TRANSPORT_METADATA_KEY);
copy_non_empty_string(source, target, "request_path");
copy_non_empty_string(source, target, "request_query_string");
copy_non_empty_string(source, target, "request_path_and_query");
@@ -402,6 +405,8 @@ fn move_allowed_metadata_fields(mut source: Map<String, Value>, target: &mut Map
remove_bool(&mut source, target, UPSTREAM_IS_STREAM_KEY);
remove_non_null_value(&mut source, target, "client_session_affinity");
remove_bool(&mut source, target, "api_key_is_standalone");
remove_bool(&mut source, target, WEBSOCKET_MODE_METADATA_KEY);
remove_non_empty_string(&mut source, target, WEBSOCKET_TRANSPORT_METADATA_KEY);
remove_non_empty_string(&mut source, target, "request_path");
remove_non_empty_string(&mut source, target, "request_query_string");
remove_non_empty_string(&mut source, target, "request_path_and_query");
@@ -907,6 +912,24 @@ mod tests {
);
}
#[test]
fn sanitizes_websocket_transport_metadata() {
let metadata = sanitize_usage_request_metadata(Some(json!({
"websocket_mode": true,
"websocket_transport": "responses",
"untrusted_field": "drop-me",
})))
.expect("WebSocket metadata should remain");
assert_eq!(
metadata,
json!({
"websocket_mode": true,
"websocket_transport": "responses",
})
);
}
#[test]
fn sanitizes_request_path_query_metadata() {
let metadata = sanitize_usage_request_metadata(Some(json!({
+34 -1
View File
@@ -2,7 +2,10 @@ use std::collections::BTreeMap;
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
use aether_contracts::{ExecutionPlan, ExecutionTelemetry};
use aether_data_contracts::repository::usage::{UpsertUsageRecord, UsageBodyCaptureState};
use aether_data_contracts::repository::usage::{
UpsertUsageRecord, UsageBodyCaptureState, WEBSOCKET_MODE_METADATA_KEY,
WEBSOCKET_TRANSPORT_METADATA_KEY,
};
use aether_data_contracts::DataLayerError;
use base64::Engine as _;
use serde_json::{json, Map, Value};
@@ -2114,6 +2117,18 @@ fn build_runtime_request_metadata_seed_from_parts(
Value::Bool(api_key_is_standalone),
);
}
if let Some(websocket_mode) = context_bool(context, WEBSOCKET_MODE_METADATA_KEY) {
metadata.insert(
WEBSOCKET_MODE_METADATA_KEY.to_string(),
Value::Bool(websocket_mode),
);
}
if let Some(websocket_transport) = context_string(context, WEBSOCKET_TRANSPORT_METADATA_KEY) {
metadata.insert(
WEBSOCKET_TRANSPORT_METADATA_KEY.to_string(),
Value::String(websocket_transport),
);
}
let provider_source_bytes = provider_request_body_base64.and_then(decoded_base64_len_hint);
append_runtime_body_capture_metadata(
&mut metadata,
@@ -3763,6 +3778,8 @@ mod tests {
Some(&json!({
"candidate_id": "cand-pending-event-1",
"candidate_index": 3,
"websocket_mode": true,
"websocket_transport": "responses",
"original_request_body": {"messages": [{"content": "omit me"}]},
"provider_request_body": {
"input": [{"type": "compaction_trigger"}]
@@ -3785,6 +3802,22 @@ mod tests {
assert!(record.provider_request_body.is_none());
assert_eq!(record.candidate_id.as_deref(), Some("cand-pending-event-1"));
assert_eq!(record.candidate_index, Some(3));
assert_eq!(
record
.request_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("websocket_mode")),
Some(&json!(true))
);
assert_eq!(
record
.request_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("websocket_transport")),
Some(&json!("responses"))
);
}
#[test]