From 0d4449230901c21735b0758c8fffcafef6d8ea86 Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 9 Nov 2026 11:16:15 +0800 Subject: [PATCH] fix(openai): scope active quota window reassignment to image endpoints Co-authored-by: Codex --- .../gateway-protocol/src/openai/events.rs | 26 +++++++++++-------- .../gateway-protocol/tests/openai/events.rs | 19 ++++++++------ .../openai/src/provider/execution.rs | 6 ++++- .../providers/openai/src/provider/failure.rs | 9 ++++++- .../providers/openai/src/provider/live.rs | 1 + .../openai/src/provider/observation.rs | 11 +++++--- .../openai/tests/credential/quota/mod.rs | 3 ++- .../openai/tests/provider/contract/mod.rs | 2 +- docs/architecture.md | 7 ++--- 9 files changed, 55 insertions(+), 29 deletions(-) diff --git a/backend/crates/gateway-protocol/src/openai/events.rs b/backend/crates/gateway-protocol/src/openai/events.rs index e067a51e2..d9fdaa1c1 100644 --- a/backend/crates/gateway-protocol/src/openai/events.rs +++ b/backend/crates/gateway-protocol/src/openai/events.rs @@ -328,7 +328,7 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option Option>(); - // 生图等响应只通过 active-limit 标明通用 x-codex 窗口的归属 - // 有独立具名头时保留各桶原始事实,避免把真正的 Codex 窗口一起迁移 - if let Some(active_limit) = active_limit.as_ref() - && active_limit != "codex" - && !limits.contains_key(active_limit) - && let Some(mut details) = limits.remove("codex") - { - details.limit_id.clone_from(active_limit); - limits.insert(active_limit.clone(), details); - } let credits = parse_credits_from_lookup(&normalized); let plan_type = lookup_non_empty(&normalized, "x-codex-plan-type"); let promo_message = lookup_non_empty(&normalized, "x-codex-promo-message"); @@ -371,6 +361,20 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option Option { + let mut parsed = parse_rate_limit_headers(headers)?; + if let Some(active) = &parsed.active_limit + && active != "codex" + && !parsed.limits.contains_key(active) + && let Some(mut details) = parsed.limits.remove("codex") + { + details.limit_id.clone_from(active); + parsed.limits.insert(active.clone(), details); + } + Some(parsed) +} + /// 判断响应头是否属于可被动同步的限流领域 pub fn is_rate_limit_header_name(name: &str) -> bool { let normalized = name.to_ascii_lowercase(); diff --git a/backend/crates/gateway-protocol/tests/openai/events.rs b/backend/crates/gateway-protocol/tests/openai/events.rs index 46120233b..33ef03370 100644 --- a/backend/crates/gateway-protocol/tests/openai/events.rs +++ b/backend/crates/gateway-protocol/tests/openai/events.rs @@ -2,8 +2,9 @@ use gateway_protocol::openai::events::{ RateLimitKeySource, RateLimitWindow, TokenUsage, billable_usage_is_complete, extract_sse_usage, - extract_usage, is_codex_quota_header_name, is_rate_limit_header_name, parse_rate_limit_headers, - parse_rate_limits_event, rate_limits_to_header_pairs, retry_after_seconds_from_body, + extract_usage, is_codex_quota_header_name, is_rate_limit_header_name, + parse_image_rate_limit_headers, parse_rate_limit_headers, parse_rate_limits_event, + rate_limits_to_header_pairs, retry_after_seconds_from_body, }; use serde_json::json; @@ -588,7 +589,7 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata() } #[test] -fn parse_rate_limit_headers_should_assign_generic_windows_to_the_active_limit() { +fn image_headers_assign_generic_windows_to_the_active_limit_only_without_its_own_headers() { let headers = [ ("x-codex-active-limit", "imagegen_premium"), ("x-codex-primary-used-percent", "0"), @@ -600,7 +601,7 @@ fn parse_rate_limit_headers_should_assign_generic_windows_to_the_active_limit() ] .map(|(name, value)| (name.to_owned(), value.to_owned())); - let parsed = parse_rate_limit_headers(&headers).expect("image rate-limit headers"); + let parsed = parse_image_rate_limit_headers(&headers).expect("image rate-limit headers"); let image = parsed.limits.get("imagegen_premium").expect("image limit"); assert_eq!(image.limit_id, "imagegen_premium"); assert_eq!(image.key_source, RateLimitKeySource::LimitId); @@ -613,10 +614,12 @@ fn parse_rate_limit_headers_should_assign_generic_windows_to_the_active_limit() }) ); assert!(!parsed.limits.contains_key("codex")); - assert_eq!( - parse_rate_limit_headers(&rate_limits_to_header_pairs(&parsed)), - Some(parsed) - ); + let mut headers = rate_limits_to_header_pairs(&parsed); + headers.push(("x-codex-primary-used-percent".to_owned(), "89".to_owned())); + let separate = + parse_image_rate_limit_headers(&headers).expect("separate image and core limits"); + assert_eq!(separate.limits["imagegen_premium"], *image); + assert_eq!(separate.limits["codex"].primary.unwrap().used_percent, 89.0); } #[test] diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index c33f28f9f..a3691abe4 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -641,6 +641,7 @@ pub(super) async fn create_json_attempt( pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream { Box::pin(async_stream::try_stream! { let allows_account_state_mutation = request.lease.allows_account_state_mutation(); + let image_endpoint = matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH); let failure_context = OpenAiFailureContext { client: &request.client, selector: &request.selector, @@ -648,6 +649,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea response_origin: &request.response_origin, cyber_policy_scope: None, allows_account_state_mutation, + image_endpoint, upstream_model: None, }; let active_account = request.lease.account().clone(); @@ -713,6 +715,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea &request.quota, &active_account, &response.rate_limit_headers, + image_endpoint, ) .await; if !response.set_cookie_headers.is_empty() @@ -736,7 +739,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea let response_meta = ResponseMeta::for_provider_endpoint(request.context.request_id().as_str()); yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone())); - if matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH) + if image_endpoint && let Some((usage, cost)) = image_response_metering(&request.body, &response.body, request.context.pricing()) { yield ProviderEvent::canonical(GatewayEvent::Usage(usage)); @@ -812,6 +815,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream { let allows_account_state_mutation = lease.allows_account_state_mutation(); let failure_context = OpenAiFailureContext { client: &client, + image_endpoint: false, selector: &selector, quota: "a, response_origin: &response_origin, diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index 4691fae19..4bc47e8e6 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -161,6 +161,7 @@ pub(super) struct OpenAiFailureContext<'a> { pub(super) response_origin: &'a Url, pub(super) cyber_policy_scope: Option<&'a CodexCyberPolicyScope>, pub(super) allows_account_state_mutation: bool, + pub(super) image_endpoint: bool, pub(super) upstream_model: Option<&'a UpstreamModelId>, } @@ -310,7 +311,13 @@ pub(super) async fn apply_failure( if !context.allows_account_state_mutation { return; } - synchronize_passive_quota_headers(context.quota, account, &failure.rate_limit_headers).await; + synchronize_passive_quota_headers( + context.quota, + account, + &failure.rate_limit_headers, + context.image_endpoint, + ) + .await; if failure.model_unsupported && let Some(model) = context.upstream_model && let Err(error) = context diff --git a/backend/crates/providers/openai/src/provider/live.rs b/backend/crates/providers/openai/src/provider/live.rs index bcda56f0a..24af23d9d 100644 --- a/backend/crates/providers/openai/src/provider/live.rs +++ b/backend/crates/providers/openai/src/provider/live.rs @@ -418,6 +418,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream { let allows_account_state_mutation = request.lease.allows_account_state_mutation(); let failure_context = OpenAiFailureContext { client: &client, + image_endpoint: false, selector: &request.selector, quota: &request.quota, response_origin: &request.response_origin, diff --git a/backend/crates/providers/openai/src/provider/observation.rs b/backend/crates/providers/openai/src/provider/observation.rs index d6eceafcc..3b83d3254 100644 --- a/backend/crates/providers/openai/src/provider/observation.rs +++ b/backend/crates/providers/openai/src/provider/observation.rs @@ -553,11 +553,16 @@ pub(super) async fn synchronize_passive_quota_headers( quota: &CodexCredentialQuotaService, account: &ProviderAccount, headers: &[(String, String)], + image_endpoint: bool, ) { - let Some(rate_limits) = parse_rate_limit_headers(headers) else { - return; + let parsed = if image_endpoint { + gateway_protocol::openai::events::parse_image_rate_limit_headers(headers) + } else { + parse_rate_limit_headers(headers) }; - synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await; + if let Some(rate_limits) = parsed { + synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await; + } } pub(super) const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str { diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index 5111f7d2f..500aaf1b6 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -276,7 +276,8 @@ async fn passive_quota_updates_keep_core_and_model_specific_buckets_independent( assert_eq!(spark.used_percent(), Some(1.0)); let core_headers = vec![ - ("x-codex-active-limit".to_owned(), "codex".to_owned()), + // 文本响应的活动标记不是独立额度头,通用周窗口仍应更新原来的账号额度 + ("x-codex-active-limit".to_owned(), "premium".to_owned()), ("x-codex-primary-used-percent".to_owned(), "82".to_owned()), ( "x-codex-primary-window-minutes".to_owned(), diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index de94d5593..f117cb03e 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -10191,7 +10191,7 @@ async fn successful_response_with_full_subscription_blocks_subsequent_requests() .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") - .insert_header("x-codex-active-limit", "codex") + .insert_header("x-codex-active-limit", "premium") .insert_header("x-codex-primary-used-percent", "100") .insert_header("x-codex-primary-window-minutes", "300") .insert_header("x-codex-primary-reset-at", "1900000000") diff --git a/docs/architecture.md b/docs/architecture.md index 250bc7cd3..8bcb5d858 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1031,9 +1031,10 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota OpenAI Provider 将其中明确的套餐变更与额度原子提交,共用凭据版本和观察时间保护; 空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料 -OpenAI 响应头的额度窗口由 `gateway-protocol` 按上游计量项归属解析;仅通过 `x-codex-active-limit` -标明具名额度时,通用 `x-codex-*` 窗口归入该计量项。Provider 按计量项独立保存,生图等具名额度 -不覆盖默认 Codex 窗口,也不改变账号级套餐访问结论 +OpenAI 响应头的额度窗口按头名前缀归属,普通 Responses 的 `x-codex-primary-*` 和 +`x-codex-secondary-*` 更新账号通用额度。 +仅在图片生成和编辑端点,若 `x-codex-active-limit` 指向具名计量项且没有该项的独立额度头, +通用窗口才归入该计量项。Provider 按计量项独立保存,具名额度不改变账号级套餐访问结论 OpenAI Provider 统一解释点数可用性,主动查询、耗尽恢复与失败回读复用同一规则。 Store 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。