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