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
@@ -1300,6 +1300,10 @@ fn provider_query_pool_catalog_key_context(
provider_type,
quota_snapshot,
),
quota_hard_blocked: admin_provider_pool_pure::admin_pool_key_quota_hard_blocked(
key,
provider_type,
),
health_score,
latency_avg_ms,
catalog_lru_score: Some(key.last_used_at_unix_secs.unwrap_or(0) as f64),
@@ -154,6 +154,8 @@ pub(crate) struct AdminProviderCreateRequest {
#[serde(default)]
pub(crate) codex_fingerprint_convergence_enabled: Option<bool>,
#[serde(default)]
pub(crate) responses_websocket_enabled: Option<bool>,
#[serde(default)]
pub(crate) is_active: Option<bool>,
#[serde(default)]
pub(crate) concurrent_limit: Option<i32>,
@@ -215,6 +217,8 @@ pub(crate) struct AdminProviderUpdateRequest {
#[serde(default)]
pub(crate) codex_fingerprint_convergence_enabled: Option<bool>,
#[serde(default)]
pub(crate) responses_websocket_enabled: Option<bool>,
#[serde(default)]
pub(crate) is_active: Option<bool>,
#[serde(default)]
pub(crate) concurrent_limit: Option<i32>,
@@ -4,7 +4,7 @@ use crate::handlers::admin::provider::shared::support::{
};
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::handlers::public::{request_candidate_event_unix_ms, request_candidate_status_label};
use crate::orchestration::codex_cyber_flag_passthrough_enabled;
use crate::orchestration::{codex_cyber_flag_passthrough_enabled, responses_websocket_adapter};
use crate::provider_key_auth::provider_key_effective_api_formats;
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, StoredRequestCandidate,
@@ -222,6 +222,7 @@ pub(crate) fn build_admin_provider_summary_value(
&provider.provider_type,
provider.config.as_ref(),
),
"responses_websocket_enabled": responses_websocket_adapter(&provider.provider_type, provider.config.as_ref()).is_some(),
"ops_quota_alert_enabled": ops_quota_alert_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
@@ -208,6 +208,53 @@ pub(crate) fn normalize_chat_pii_redaction_config(
}
}
pub(crate) fn set_responses_websocket_enabled(
config: &mut serde_json::Map<String, serde_json::Value>,
enabled: bool,
) -> Result<(), String> {
let mut responses = match config.remove("responses_websocket") {
None => serde_json::Map::new(),
Some(serde_json::Value::Object(config)) => config,
Some(_) => return Err("config.responses_websocket 必须是 JSON 对象".to_string()),
};
responses.insert("enabled".to_string(), serde_json::Value::Bool(enabled));
config.insert(
"responses_websocket".to_string(),
serde_json::Value::Object(responses),
);
Ok(())
}
pub(crate) fn remove_responses_websocket_enabled(
config: &mut serde_json::Map<String, serde_json::Value>,
) {
let Some(serde_json::Value::Object(responses)) = config.get_mut("responses_websocket") else {
return;
};
responses.remove("enabled");
if responses.is_empty() {
config.remove("responses_websocket");
}
}
pub(crate) fn validate_responses_websocket_config(
config: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), String> {
if let Some(value) = config.get("responses_websocket") {
let responses = value
.as_object()
.ok_or_else(|| "config.responses_websocket 必须是 JSON 对象".to_string())?;
let enabled = responses
.get("enabled")
.ok_or_else(|| "config.responses_websocket.enabled 为必填布尔值".to_string())?;
if !enabled.is_boolean() {
return Err("config.responses_websocket.enabled 必须是布尔值".to_string());
}
}
Ok(())
}
pub(crate) fn validate_vertex_api_formats(
provider_type: &str,
auth_type: &str,
@@ -258,7 +305,9 @@ mod tests {
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
normalize_chat_pii_redaction_config, normalize_pool_advanced_config,
normalize_provider_type_input, normalize_rate_multipliers,
reconcile_allow_auth_channel_mismatch_formats, validate_vertex_api_formats,
reconcile_allow_auth_channel_mismatch_formats, remove_responses_websocket_enabled,
set_responses_websocket_enabled, validate_responses_websocket_config,
validate_vertex_api_formats,
};
use serde_json::json;
@@ -317,6 +366,21 @@ mod tests {
);
}
#[test]
fn responses_websocket_setting_is_available_to_explicitly_enabled_providers() {
let mut config = serde_json::Map::new();
set_responses_websocket_enabled(&mut config, true)
.expect("Responses setting should be accepted");
assert_eq!(
config.get("responses_websocket"),
Some(&json!({"enabled": true}))
);
validate_responses_websocket_config(&config).expect("Responses setting should validate");
remove_responses_websocket_enabled(&mut config);
assert!(config.get("responses_websocket").is_none());
}
#[test]
fn normalize_auth_type_supports_bearer() {
assert_eq!(
@@ -7,6 +7,8 @@ use crate::handlers::admin::provider::shared::support::{
use crate::handlers::admin::provider::write::normalize::normalize_chat_pii_redaction_config;
use crate::handlers::admin::provider::write::normalize::normalize_pool_advanced_config;
use crate::handlers::admin::provider::write::normalize::normalize_provider_type_input;
use crate::handlers::admin::provider::write::normalize::set_responses_websocket_enabled;
use crate::handlers::admin::provider::write::normalize::validate_responses_websocket_config;
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::normalize_json_object;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
@@ -179,6 +181,10 @@ pub(crate) async fn build_admin_create_provider_record(
config_map.insert("chat_pii_redaction".to_string(), value);
}
}
if let Some(enabled) = payload.responses_websocket_enabled {
set_responses_websocket_enabled(&mut config_map, enabled)?;
}
validate_responses_websocket_config(&config_map)?;
let config = (!config_map.is_empty()).then_some(serde_json::Value::Object(config_map));
crate::provider_transport::validate_anthropic_compatibility_profile_config(config.as_ref())
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())?;
@@ -7,6 +7,8 @@ use crate::handlers::admin::provider::shared::support::{
use crate::handlers::admin::provider::write::normalize::normalize_chat_pii_redaction_config;
use crate::handlers::admin::provider::write::normalize::normalize_pool_advanced_config;
use crate::handlers::admin::provider::write::normalize::normalize_provider_type_input;
use crate::handlers::admin::provider::write::normalize::set_responses_websocket_enabled;
use crate::handlers::admin::provider::write::normalize::validate_responses_websocket_config;
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::normalize_json_object;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
@@ -337,6 +339,14 @@ pub(crate) async fn build_admin_update_provider_record(
}
}
if fields.contains("responses_websocket_enabled") {
let enabled = payload
.responses_websocket_enabled
.ok_or_else(|| "responses_websocket_enabled 必须是布尔值".to_string())?;
set_responses_websocket_enabled(&mut config_map, enabled)?;
}
validate_responses_websocket_config(&config_map)?;
updated.config = (!config_map.is_empty()).then_some(serde_json::Value::Object(config_map));
crate::provider_transport::validate_anthropic_compatibility_profile_config(
updated.config.as_ref(),