fix(openai): scope active quota window reassignment to image endpoints

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-09 11:16:15 +08:00
co-authored by Codex
parent 31f471b051
commit 0d44492309
9 changed files with 55 additions and 29 deletions
@@ -328,7 +328,7 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
limit_ids.insert(active_limit.clone());
}
limit_ids.insert("codex".to_string());
let mut limits = limit_ids
let limits = limit_ids
.into_iter()
.filter_map(|limit_id| {
let prefix = format!("x-{}", limit_id.replace('_', "-"));
@@ -336,16 +336,6 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
.map(|details| (limit_id, details))
})
.collect::<BTreeMap<_, _>>();
// 生图等响应只通过 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<ParsedRa
})
}
/// Images 端点有时仅用 active-limit 标明通用窗口属于哪个图片计量项
pub fn parse_image_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRateLimits> {
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();
@@ -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]
@@ -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: &quota,
response_origin: &response_origin,
@@ -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
@@ -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,
@@ -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 {
@@ -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(),
@@ -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")
+4 -3
View File
@@ -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 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。