mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user