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
@@ -967,6 +967,12 @@ fn build_codex_quota_status_snapshot(
let credits_unlimited = metadata
.get("credits_unlimited")
.and_then(admin_provider_quota_pure::coerce_json_bool);
let allowed = metadata
.get("allowed")
.and_then(admin_provider_quota_pure::coerce_json_bool);
let limit_reached = metadata
.get("limit_reached")
.and_then(admin_provider_quota_pure::coerce_json_bool);
let reset_credits = build_codex_reset_credits_status_snapshot(metadata, observed_at_unix_secs);
let windows = [
@@ -996,6 +1002,8 @@ fn build_codex_quota_status_snapshot(
&& credits_has_credits.is_none()
&& credits_balance.is_none()
&& credits_unlimited.is_none()
&& allowed.is_none()
&& limit_reached.is_none()
&& reset_credits.is_none()
&& observed_at_unix_secs.is_none()
{
@@ -1031,11 +1039,19 @@ fn build_codex_quota_status_snapshot(
.filter_map(admin_provider_quota_pure::coerce_json_u64)
.min();
let reset_at = quota_windows_min_reset_at(&primary_windows);
let exhausted_by_credits = primary_windows.is_empty()
let explicitly_blocked = allowed == Some(false) || limit_reached == Some(true);
let explicitly_available =
!explicitly_blocked && (allowed == Some(true) || limit_reached == Some(false));
let exhausted_by_credits = !explicitly_available
&& primary_windows.is_empty()
&& credits_unlimited != Some(true)
&& credits_has_credits == Some(false);
let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
let exhausted = exhausted_by_credits || exhausted_by_window;
let exhausted_by_window =
!explicitly_available && usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
let exhausted_by_signal = admin_provider_quota_pure::codex_rate_limit_metadata_exhausted(
&Value::Object(metadata.clone()),
);
let exhausted = exhausted_by_signal || exhausted_by_credits || exhausted_by_window;
let mut credits = Map::new();
if let Some(value) = credits_has_credits {
@@ -1048,7 +1064,9 @@ fn build_codex_quota_status_snapshot(
credits.insert("unlimited".to_string(), json!(value));
}
let reason = if exhausted_by_credits {
let reason = if exhausted_by_signal {
Some("上游已拒绝继续使用该账号")
} else if exhausted_by_credits {
Some("无可用积分")
} else if exhausted_by_window {
Some("额度窗口已耗尽")
@@ -1071,6 +1089,8 @@ fn build_codex_quota_status_snapshot(
"reset_at": reset_at,
"reset_seconds": reset_seconds,
"plan_type": plan_type,
"allowed": allowed,
"limit_reached": limit_reached,
"credits": if credits.is_empty() {
Value::Null
} else {
@@ -3833,6 +3853,28 @@ mod tests {
);
}
#[test]
fn sync_provider_key_quota_status_snapshot_honors_codex_limit_signal() {
let payload = sync_provider_key_quota_status_snapshot(
None,
"codex",
Some(&json!({
"codex": {
"updated_at": 1_775_800_000u64,
"allowed": false,
"limit_reached": true
}
})),
"websocket_response_body",
)
.expect("explicit Codex limit signal should build a quota snapshot");
assert_eq!(payload.pointer("/quota/code"), Some(&json!("exhausted")));
assert_eq!(payload.pointer("/quota/exhausted"), Some(&json!(true)));
assert_eq!(payload.pointer("/quota/allowed"), Some(&json!(false)));
assert_eq!(payload.pointer("/quota/limit_reached"), Some(&json!(true)));
}
#[test]
fn sync_provider_key_quota_status_snapshot_drops_codex_usage_state_when_window_resets() {
let current_status_snapshot = json!({