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
+7
View File
@@ -111,6 +111,13 @@ pub fn admin_pool_key_account_quota_exhausted(
aether_provider_pool::provider_pool_key_account_quota_exhausted(key, provider_type)
}
pub fn admin_pool_key_quota_hard_blocked(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> bool {
aether_provider_pool::provider_pool_key_quota_hard_blocked(key, provider_type)
}
fn admin_pool_has_proxy(key: &StoredProviderCatalogKey) -> bool {
match key.proxy.as_ref() {
Some(Value::Object(values)) => !values.is_empty(),
+268 -1
View File
@@ -2019,6 +2019,174 @@ pub fn parse_codex_wham_usage_response(
Some(serde_json::Value::Object(result))
}
/// Normalizes quota metadata emitted by the Codex Responses WebSocket.
///
/// The upstream normally sends a `codex.rate_limits` item inside a `chunks`
/// envelope. When the account is already exhausted it can instead send a
/// terminal `usage_limit_reached` error whose embedded `X-Codex-*` headers
/// contain the authoritative final quota snapshot.
pub fn parse_codex_websocket_rate_limits_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let mut latest = parse_codex_websocket_quota_event(value, updated_at_unix_secs);
for chunk in value
.get("chunks")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
{
if let Some(parsed) = parse_codex_websocket_quota_event(chunk, updated_at_unix_secs) {
latest = Some(parsed);
}
}
latest
}
fn parse_codex_websocket_quota_event(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
parse_codex_websocket_rate_limits_chunk(value, updated_at_unix_secs)
.or_else(|| parse_codex_websocket_usage_limit_error(value, updated_at_unix_secs))
}
/// Returns whether normalized Codex rate-limit metadata says that the account
/// cannot accept another request. Explicit upstream flags take precedence, and
/// the percentage fallback keeps older payloads working when those flags are
/// absent.
pub fn codex_rate_limit_metadata_exhausted(value: &serde_json::Value) -> bool {
let allowed = value.get("allowed").and_then(coerce_json_bool);
let limit_reached = value.get("limit_reached").and_then(coerce_json_bool);
if allowed == Some(false) || limit_reached == Some(true) {
return true;
}
if allowed == Some(true) || limit_reached == Some(false) {
return false;
}
["primary_used_percent", "secondary_used_percent"]
.into_iter()
.filter_map(|key| value.get(key))
.filter_map(coerce_json_f64)
.any(|used_percent| used_percent >= 100.0 - 1e-6)
}
fn parse_codex_websocket_rate_limits_chunk(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let root = value.as_object()?;
if root.get("type").and_then(serde_json::Value::as_str) != Some("codex.rate_limits") {
return None;
}
let rate_limits = root
.get("rate_limits")
.and_then(serde_json::Value::as_object)?;
let mut result = serde_json::Map::new();
let plan_type = root
.get("plan_type")
.or_else(|| rate_limits.get("plan_type"))
.and_then(serde_json::Value::as_str)
.and_then(|value| normalize_codex_plan_type(Some(value)));
if let Some(plan_type) = plan_type {
result.insert("plan_type".to_string(), json!(plan_type));
}
if let Some(allowed) = rate_limits.get("allowed").and_then(coerce_json_bool) {
result.insert("allowed".to_string(), json!(allowed));
}
if let Some(limit_reached) = rate_limits.get("limit_reached").and_then(coerce_json_bool) {
result.insert("limit_reached".to_string(), json!(limit_reached));
}
if let Some(primary) = rate_limits
.get("primary")
.and_then(serde_json::Value::as_object)
{
codex_write_window(&mut result, primary, "primary");
}
if let Some(secondary) = rate_limits
.get("secondary")
.and_then(serde_json::Value::as_object)
{
codex_write_window(&mut result, secondary, "secondary");
}
if result.is_empty() {
return None;
}
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
Some(serde_json::Value::Object(result))
}
fn parse_codex_websocket_usage_limit_error(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let root = value.as_object()?;
if root.get("type").and_then(serde_json::Value::as_str) != Some("error") {
return None;
}
let status_code = root
.get("status_code")
.or_else(|| root.get("status"))
.and_then(coerce_json_u64);
if status_code != Some(429) {
return None;
}
let error = root.get("error").and_then(serde_json::Value::as_object)?;
if error.get("type").and_then(serde_json::Value::as_str) != Some("usage_limit_reached") {
return None;
}
let headers = root
.get("headers")
.and_then(serde_json::Value::as_object)
.map(|headers| {
headers
.iter()
.filter_map(|(name, value)| {
value
.as_str()
.map(|value| (name.clone(), value.to_string()))
})
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
let mut result = parse_codex_usage_headers(&headers, updated_at_unix_secs)
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
if !result.contains_key("plan_type") {
if let Some(plan_type) = error
.get("plan_type")
.and_then(serde_json::Value::as_str)
.and_then(|value| normalize_codex_plan_type(Some(value)))
{
result.insert("plan_type".to_string(), json!(plan_type));
}
}
if !result.contains_key("primary_reset_at") {
if let Some(reset_at) = error.get("resets_at").and_then(coerce_json_u64) {
result.insert("primary_reset_at".to_string(), json!(reset_at));
}
}
if !result.contains_key("primary_reset_after_seconds") {
if let Some(reset_after_seconds) = error.get("resets_in_seconds").and_then(coerce_json_u64)
{
result.insert(
"primary_reset_after_seconds".to_string(),
json!(reset_after_seconds),
);
}
}
// `usage_limit_reached` is a definitive, account-wide terminal signal.
// Preserve that fact even if an intermediary strips some Codex headers.
result.insert("allowed".to_string(), json!(false));
result.insert("limit_reached".to_string(), json!(true));
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
Some(serde_json::Value::Object(result))
}
fn parse_codex_reset_credit_timestamp(value: Option<&serde_json::Value>) -> Option<u64> {
let value = value?;
if let Some(timestamp) = coerce_json_u64(value) {
@@ -3374,10 +3542,11 @@ pub fn parse_chatgpt_web_conversation_init_response(
mod tests {
use super::{
codex_build_invalid_state, codex_oauth_success_request_order_is_stale,
codex_rate_limit_metadata_exhausted,
codex_runtime_invalid_reason, extract_execution_error_detail,
merge_codex_quota_metadata_snapshot, normalize_codex_reset_credit_consume_outcome,
parse_antigravity_usage_response, parse_chatgpt_web_conversation_init_response,
parse_codex_backend_me_response, parse_codex_usage_headers,
parse_codex_usage_headers, parse_codex_websocket_rate_limits_response,
parse_codex_wham_reset_credits_detail_response, parse_codex_wham_usage_response,
parse_gemini_cli_retrieve_user_quota_response,
parse_gemini_cli_v1internal_credits_response, parse_windsurf_model_configs_response,
@@ -5395,6 +5564,104 @@ mod tests {
assert!(parsed.get("secondary_window_minutes").is_none());
}
#[test]
fn parses_codex_websocket_rate_limits_from_chunk_envelope() {
let parsed = parse_codex_websocket_rate_limits_response(
&json!({
"chunks": [
{"type": "response.output_text.delta", "delta": "ignored"},
{
"type": "codex.rate_limits",
"plan_type": "free",
"rate_limits": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 91,
"window_minutes": 43200,
"reset_after_seconds": 2590791,
"reset_at": 1787154563u64
}
}
}
]
}),
1_787_000_000,
)
.expect("Codex WebSocket quota chunk should parse");
assert_eq!(parsed.get("plan_type"), Some(&json!("free")));
assert_eq!(parsed.get("allowed"), Some(&json!(true)));
assert_eq!(parsed.get("limit_reached"), Some(&json!(false)));
assert_eq!(parsed.get("primary_used_percent"), Some(&json!(91.0)));
assert_eq!(
parsed.get("primary_window_minutes"),
Some(&json!(43_200u64))
);
assert_eq!(
parsed.get("primary_reset_after_seconds"),
Some(&json!(2_590_791u64))
);
assert!(parsed.get("secondary_used_percent").is_none());
}
#[test]
fn parses_codex_websocket_usage_limit_error_headers() {
let parsed = parse_codex_websocket_rate_limits_response(
&json!({
"type": "error",
"error": {
"type": "usage_limit_reached",
"plan_type": "free",
"resets_at": 1_787_274_385u64,
"resets_in_seconds": 2_590_077u64,
},
"status_code": 429,
"headers": {
"X-Codex-Plan-Type": "free",
"X-Codex-Primary-Used-Percent": "100",
"X-Codex-Primary-Window-Minutes": "43200",
"X-Codex-Primary-Reset-After-Seconds": "2590078",
"X-Codex-Primary-Reset-At": "1787274385",
"X-Codex-Credits-Has-Credits": "False",
},
}),
1_787_000_000,
)
.expect("Codex usage-limit error should parse as quota metadata");
assert_eq!(parsed.get("allowed"), Some(&json!(false)));
assert_eq!(parsed.get("limit_reached"), Some(&json!(true)));
assert_eq!(parsed.get("plan_type"), Some(&json!("free")));
assert_eq!(parsed.get("primary_used_percent"), Some(&json!(100.0)));
assert_eq!(
parsed.get("primary_reset_at"),
Some(&json!(1_787_274_385u64))
);
assert!(codex_rate_limit_metadata_exhausted(&parsed));
}
#[test]
fn codex_rate_limit_metadata_detects_explicit_and_window_exhaustion() {
assert!(codex_rate_limit_metadata_exhausted(&json!({
"allowed": false
})));
assert!(codex_rate_limit_metadata_exhausted(&json!({
"limit_reached": true
})));
assert!(codex_rate_limit_metadata_exhausted(&json!({
"primary_used_percent": 100
})));
assert!(!codex_rate_limit_metadata_exhausted(&json!({
"allowed": true,
"primary_used_percent": 100
})));
assert!(!codex_rate_limit_metadata_exhausted(&json!({
"limit_reached": false,
"secondary_used_percent": 100
})));
}
#[test]
fn parses_codex_reset_credit_count_from_wham_usage() {
let parsed = parse_codex_wham_usage_response(