mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
fix(openai): scope active quota window reassignment to image endpoints
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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: "a,
|
||||
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")
|
||||
|
||||
@@ -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 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。
|
||||
|
||||
Reference in New Issue
Block a user