RateLimit与配额状态
本文承接 Responses重试策略、模型错误分类 和 流式推理与消息Delta。前文已经说明 429、usage limit 和 retry delay 的错误路径;本文回答另一个问题:一次响应里的限额信息如何变成 Session 中可被 UI 和下一次状态查询消费的快照?
本文关注的是 RateLimitSnapshot 的生命周期,而不是服务端如何计算配额。源码中有两类输入:HTTP response headers 提供请求开始时的初始快照,WebSocket 的 codex.rate_limits 事件提供会话中的更新;两类输入最终都变成 ResponseEvent::RateLimits,由 Core 归并到 SessionState::latest_rate_limits。
读完后,读者应能解释三个看似矛盾的现象:为什么空 HTTP headers 仍可能产生默认 codex 快照,为什么后续稀疏更新不会清空旧 credits,为什么 Core 收到多个限额事件却不会为每个事件都立即发送一个 TokenCount。
1. 快照主线
限额状态不是从 parser 直接写入 UI,而是经过 API、Core Session 和协议事件三层。API 只负责把异构输入转换成结构化快照;Session 是持久 owner;TokenCount 是对外通知载体。
| 阶段 | owner | 输入 | 输出 | 生效时机 |
|---|---|---|---|---|
| 解析 | codex-api | header 或 JSON event | RateLimitSnapshot | stream 创建或收到事件 |
| 归一化 | ResponseEvent | 单个 snapshot | 统一 API 事件 | API→Core channel |
| 合并 | SessionState | 新旧 snapshot | latest snapshot | Core 消费事件时 |
| 通知 | Session | token info + latest limits | TokenCount | turn 安全点或显式更新 |
| 展示 | TUI/App Server/SDK | 协议事件 | 状态栏、状态接口 | 各消费者自己的刷新周期 |
这个分层也限定了覆盖范围:RateLimitSnapshot 只表示客户端观察到的配额状态,不代表服务端限额实时一致,更不代表 used_percent 可以直接推导下一次请求一定成功或失败。
2. 数据结构
协议层的快照同时容纳窗口、credits、计划和触达类型。Option 的含义不是“字段永远不存在”,而是该来源没有提供该字段;后面的 merge 逻辑会利用这一点保留旧值。
源码位置:codex-rs/protocol/src/protocol.rs :: RateLimitSnapshot、RateLimitWindow、CreditsSnapshot
pub struct RateLimitSnapshot {
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub spend_control_reached: Option<bool>,
pub plan_type: Option<crate::account::PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
pub struct RateLimitWindow {
pub used_percent: f64,
pub window_minutes: Option<i64>,
pub resets_at: Option<i64>,
}
pub struct CreditsSnapshot {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}primary 和 secondary 是两个滚动窗口,不是两个独立账户;credits 是额度信息,不等价于窗口百分比;rate_limit_reached_type 则描述触达原因。教程阅读时应先区分“窗口消耗”和“账户额度”,再追它们分别由哪个输入填充。
3. Header解析
HTTP headers 由 parse_all_rate_limits 发现限制族,再调用 parse_rate_limit_for_limit 逐族解析。默认族始终先处理,额外族通过 header 名的 -primary-used-percent 后缀反向发现。
源码位置:codex-rs/codex-api/src/rate_limits.rs :: parse_all_rate_limits
pub fn parse_all_rate_limits(headers: &HeaderMap) -> Vec<RateLimitSnapshot> {
let mut snapshots = Vec::new();
if let Some(snapshot) = parse_default_rate_limit(headers) {
snapshots.push(snapshot);
}
let mut limit_ids: BTreeSet<String> = BTreeSet::new();
for name in headers.keys() {
let header_name = name.as_str().to_ascii_lowercase();
if let Some(limit_id) = header_name_to_limit_id(&header_name)
&& limit_id != "codex"
{
limit_ids.insert(limit_id);
}
}
snapshots.extend(limit_ids.into_iter().filter_map(|limit_id| {
let snapshot = parse_rate_limit_for_limit(headers, Some(limit_id.as_str()))?;
has_rate_limit_data(&snapshot).then_some(snapshot)
}));
snapshots
}这里有一个容易误判的边界:默认快照不经过 has_rate_limit_data 过滤,因此空 headers 也会返回一个 limit_id = codex、窗口和 credits 均为空的记录;额外限制族则必须有窗口或 credits 数据才会保留。这是兼容默认 bucket 的策略,不是 parser 忘记过滤。
按族解析时,字段名由 normalized limit id 拼成 header 前缀,并且窗口只有在存在非零数据时才产生。
源码位置:codex-rs/codex-api/src/rate_limits.rs :: parse_rate_limit_for_limit、parse_rate_limit_window
pub fn parse_rate_limit_for_limit(
headers: &HeaderMap,
limit_id: Option<&str>,
) -> Option<RateLimitSnapshot> {
let normalized_limit = limit_id
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or("codex")
.to_ascii_lowercase()
.replace('_', "-");
let prefix = format!("x-{normalized_limit}");
let primary = parse_rate_limit_window(
headers,
&format!("{prefix}-primary-used-percent"),
&format!("{prefix}-primary-window-minutes"),
&format!("{prefix}-primary-reset-at"),
);
let secondary = parse_rate_limit_window(
headers,
&format!("{prefix}-secondary-used-percent"),
&format!("{prefix}-secondary-window-minutes"),
&format!("{prefix}-secondary-reset-at"),
);
let normalized_limit_id = normalize_limit_id(normalized_limit);
let credits = parse_credits_snapshot(headers);
let limit_name_header = format!("{prefix}-limit-name");
let parsed_limit_name = parse_header_str(headers, &limit_name_header)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(std::string::ToString::to_string);
Some(RateLimitSnapshot {
limit_id: Some(normalized_limit_id),
limit_name: parsed_limit_name,
primary,
secondary,
credits,
individual_limit: None,
spend_control_reached: None,
plan_type: None,
rate_limit_reached_type: None,
})
}
fn parse_rate_limit_window(
headers: &HeaderMap,
used_percent_header: &str,
window_minutes_header: &str,
resets_at_header: &str,
) -> Option<RateLimitWindow> {
let used_percent: Option<f64> = parse_header_f64(headers, used_percent_header);
used_percent.and_then(|used_percent| {
let window_minutes = parse_header_i64(headers, window_minutes_header);
let resets_at = parse_header_i64(headers, resets_at_header);
let has_data = used_percent != 0.0
|| window_minutes.is_some_and(|minutes| minutes != 0)
|| resets_at.is_some();
has_data.then_some(RateLimitWindow {
used_percent,
window_minutes,
resets_at,
})
})
}parse_header_f64 还会拒绝非有限浮点数,布尔字段只接受 true/false 或 1/0。因此错误 header 通常表现为“字段缺失”,而不是让整个 stream 失败;这是一种局部降级,读者调试时要区分“没有快照”和“快照存在但窗口为空”。
4. SSE初始值
HTTP stream 建立成功后,spawn_response_stream 先从响应头解析快照,再把每个快照作为 channel 的前置事件发送。它与后续 SSE body 共用同一个 ResponseStream,所以 Core 不需要知道数据来自 headers 还是 body。
源码位置:codex-rs/codex-api/src/sse/responses.rs :: spawn_response_stream
pub fn spawn_response_stream(
stream_response: StreamResponse,
idle_timeout: Duration,
telemetry: Option<Arc<dyn SseTelemetry>>,
turn_state: Option<Arc<OnceLock<String>>>,
) -> ResponseStream {
let rate_limit_snapshots = parse_all_rate_limits(&stream_response.headers);
let models_etag = stream_response
.headers
.get("X-Models-Etag")
.and_then(|v| v.to_str().ok())
.map(ToString::to_string);
let server_model = stream_response
.headers
.get(OPENAI_MODEL_HEADER)
.and_then(|v| v.to_str().ok())
.map(ToString::to_string);
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent, ApiError>>(1600);
tokio::spawn(async move {
if let Some(model) = server_model {
let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await;
}
for snapshot in rate_limit_snapshots {
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
}
if let Some(etag) = models_etag {
let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
}
process_sse_with_treatment(
stream_response.bytes,
tx_event,
idle_timeout,
telemetry,
safety_buffering_treatment,
)
.await;
});
ResponseStream { rx_event }
}初始 headers 的快照在 SSE parser 读取第一帧前就排入 channel,因此它描述的是“本次 response 建立时服务端给出的状态”。它不是上一次 Session 状态的替换命令;真正的合并发生在 Core 消费 ResponseEvent::RateLimits 时。
5. WebSocket更新
WebSocket 没有 HTTP body 的 SSE 解帧,但它把文本 JSON 解析为同一类 ResponsesStreamEvent。遇到 codex.rate_limits 时,专用 parser 生成快照并立即发送 ResponseEvent::RateLimits;其他事件继续走普通 Responses 事件分类。
源码位置:codex-rs/codex-api/src/endpoint/responses_websocket.rs :: rate-limit event branch
if event.kind() == "codex.rate_limits" {
if let Some(snapshot) = parse_rate_limit_event(&text) {
let _ = tx_event
.send(Ok(ResponseEvent::RateLimits(snapshot)))
.await;
}
continue;
}parse_rate_limit_event 只接受 type = codex.rate_limits,并从 metered_limit_name 或 limit_name 生成限制族 id;事件可以同时提供窗口、credits 和 plan type。未知事件类型或 JSON 解码失败会被忽略,不会把整个 WebSocket 流标成 API error。
源码位置:codex-rs/codex-api/src/rate_limits.rs :: parse_rate_limit_event
pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
let event: RateLimitEvent = serde_json::from_str(payload).ok()?;
if event.kind != "codex.rate_limits" {
return None;
}
let (primary, secondary) = if let Some(details) = event.rate_limits.as_ref() {
(
map_event_window(details.primary.as_ref()),
map_event_window(details.secondary.as_ref()),
)
} else {
(None, None)
};
let credits = event.credits.map(|credits| CreditsSnapshot {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance,
});
let limit_id = event
.metered_limit_name
.or(event.limit_name)
.map(normalize_limit_id);
Some(RateLimitSnapshot {
limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())),
limit_name: None,
primary,
secondary,
credits,
individual_limit: None,
spend_control_reached: None,
plan_type: event.plan_type,
rate_limit_reached_type: None,
})
}HTTP header快照和 WebSocket事件快照并不完全对称:headers 可以带 limit_name 和 credits,但没有 plan type;WebSocket 事件可以带 plan type,却把 limit_name 留空。后续 Session merge 正是为了让这类异构、稀疏输入组合成可用状态。
6. Core归并
Core 的 turn reducer 收到快照后,不立即发送事件,而是写入 Session,并设置 should_emit_token_count。这个延迟让 token usage、工具等待和取消路径有机会在同一个安全点统一发出状态。
源码位置:codex-rs/core/src/session/turn.rs :: ResponseEvent::RateLimits
let mut should_emit_token_count = false;
ResponseEvent::RateLimits(snapshot) => {
// 先更新内部状态,等 token usage 可用时再发送,避免重复 TokenCount。
sess.record_rate_limits_info(snapshot).await;
should_emit_token_count = true;
}
// 工具 drain、完成或取消处理之后:
if should_emit_token_count {
// 等待需要用户输入的工具结束,避免把暂停中的进度提前发给客户端。
sess.send_token_count_event(&turn_context).await;
}这里的 owner 是 Session,不是当前 ResponseEvent。一个 turn 可能收到多个 header/event 快照,最后在 reducer 的收尾点只发一次聚合通知;如果工具请求用户输入,通知还会推迟到 pending tools resolve 之后。
7. 稀疏合并
SessionState::set_rate_limits 调用 merge_rate_limit_fields。合并不是简单的 struct 覆盖:缺少 limit_id 时归入默认 codex bucket,缺少 credits、计划、individual limit 或 spend-control 状态时保留旧值;窗口字段则使用新 snapshot 的值。
源码位置:codex-rs/core/src/state/session.rs :: set_rate_limits、merge_rate_limit_fields
pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) {
self.latest_rate_limits = Some(merge_rate_limit_fields(
self.latest_rate_limits.as_ref(),
snapshot,
));
}
fn merge_rate_limit_fields(
previous: Option<&RateLimitSnapshot>,
mut snapshot: RateLimitSnapshot,
) -> RateLimitSnapshot {
if snapshot.limit_id.is_none() {
snapshot.limit_id = Some("codex".to_string());
}
if snapshot.credits.is_none() {
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
}
if snapshot.individual_limit.is_none() {
snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone());
}
if snapshot.spend_control_reached.is_none() {
snapshot.spend_control_reached = previous.and_then(|prior| prior.spend_control_reached);
}
if snapshot.plan_type.is_none() {
snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
}
snapshot
}这个算法不是按 limit_id 保存多个 map,而是把每个新 snapshot 合并到单个 latest_rate_limits。因此同一个 Session 如果先收到 codex 再收到 codex_other,后者会成为当前 latest;它能保留旧的 account metadata,但不能证明 Session 同时展示所有限制族。额外族的全量展示需要上层另行维护或消费原始事件。
8. 通知收口
update_rate_limits 是显式更新入口,适用于 Core 已经知道需要立刻把新快照带给客户端的路径;普通 turn reducer 则使用 record_rate_limits_info 延后发送。两者最终都读取 token_info_and_rate_limits,构造同一个 TokenCountEvent。
源码位置:codex-rs/core/src/session/mod.rs :: update_rate_limits、send_token_count_event
pub(crate) async fn update_rate_limits(
&self,
turn_context: &TurnContext,
new_rate_limits: RateLimitSnapshot,
) {
self.record_rate_limits_info(new_rate_limits).await;
self.send_token_count_event(turn_context).await;
}
pub(crate) async fn record_rate_limits_info(&self, new_rate_limits: RateLimitSnapshot) {
{
let mut state = self.state.lock().await;
state.set_rate_limits(new_rate_limits);
}
}
pub(crate) async fn send_token_count_event(&self, turn_context: &TurnContext) {
let (info, rate_limits) = {
let state = self.state.lock().await;
state.token_info_and_rate_limits()
};
let event = EventMsg::TokenCount(TokenCountEvent { info, rate_limits });
self.send_event(turn_context, event).await;
}锁只保护 SessionState 的读取和写入,事件发送在释放锁之后进行,避免下游消费者回调期间持有状态锁。TokenCountEvent 同时带 token info 和 rate limits,所以 UI 可以在一个协议事件中刷新两种进度。
9. 触达类型
配额触达原因通过 x-codex-rate-limit-reached-type header 进入 RateLimitReachedType,并在 429 usage-limit bridge 中附加到 UsageLimitReachedError 及其快照。它不是普通窗口百分比的替代字段:同一个窗口可能还未达到 100%,但账户 credits 或 workspace usage 已经成为拒绝原因。
源码位置:codex-rs/codex-api/src/api_bridge.rs :: usage-limit mapping
let rate_limit_reached_type =
headers.as_ref().and_then(parse_rate_limit_reached_type);
let rate_limits = headers
.as_ref()
.and_then(|map| parse_rate_limit_for_limit(map, limit_id.as_deref()))
.map(|mut snapshot| {
snapshot.rate_limit_reached_type = rate_limit_reached_type;
snapshot
});
return CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type: err.error.plan_type,
resets_at,
rate_limits: rate_limits.map(Box::new),
promo_message,
rate_limit_reached_type,
});未知的 reached type 会被 parse() 拒绝并变成 None,而不是阻断整个错误映射。这让新服务端枚举可以在旧客户端中退化为普通 usage limit;证明的是兼容性策略,不是客户端已经理解新原因。
10. 配额测试
本文使用三类测试:header parser 证明限制族发现和字段解析,Session 测试证明稀疏 merge,API bridge 测试证明 usage-limit header 的触达类型不会丢失。
源码位置:codex-rs/codex-api/src/rate_limits.rs :: parser tests
#[test]
fn parse_all_rate_limits_reads_all_limit_families() {
let mut headers = HeaderMap::new();
headers.insert(
"x-codex-primary-used-percent",
HeaderValue::from_static("12.5"),
);
headers.insert(
"x-codex-secondary-primary-used-percent",
HeaderValue::from_static("80"),
);
let updates = parse_all_rate_limits(&headers);
assert_eq!(updates.len(), 2);
assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
assert_eq!(updates[1].limit_id.as_deref(), Some("codex_secondary"));
}
#[test]
fn parse_all_rate_limits_includes_default_codex_snapshot() {
let updates = parse_all_rate_limits(&HeaderMap::new());
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
assert_eq!(updates[0].primary, None);
}第一个测试证明默认族和额外族的发现顺序;第二个证明空 headers 的默认快照行为。它们没有证明 header 来源一定来自真实 OpenAI endpoint,也没有证明额外限制族在所有消费者中都会并列展示。
源码位置:codex-rs/core/src/session/tests.rs :: set_rate_limits_retains_previous_credits
state.set_rate_limits(initial.clone());
let update = RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 40.0,
window_minutes: Some(30),
resets_at: Some(1_800),
}),
secondary: None,
credits: None,
individual_limit: None,
spend_control_reached: None,
plan_type: None,
rate_limit_reached_type: None,
};
state.set_rate_limits(update.clone());
assert_eq!(
state.latest_rate_limits,
Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: update.primary.clone(),
secondary: update.secondary,
credits: initial.credits,
individual_limit: initial.individual_limit,
spend_control_reached: initial.spend_control_reached,
plan_type: initial.plan_type,
rate_limit_reached_type: None,
})
);断言的关键不是窗口从 10% 变成 40%,而是新快照没有 credits 时旧 credits 仍然存在;同时新的 limit_id 和窗口会生效。它没有证明 merge 会保存所有历史限制族,也没有覆盖 plan type 的另一条保留路径。
源码位置:codex-rs/codex-api/src/api_bridge_tests.rs :: map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot
for (active_limit, expected_limit_id) in
[(None, "codex"), (Some("codex_other"), "codex_other")]
{
let mut headers = HeaderMap::new();
if let Some(active_limit) = active_limit {
headers.insert(
ACTIVE_LIMIT_HEADER,
http::HeaderValue::from_static(active_limit),
);
}
for (name, value) in [
("x-codex-credits-has-credits", "true"),
("x-codex-credits-unlimited", "false"),
("x-codex-credits-balance", ""),
(
"x-codex-rate-limit-reached-type",
"workspace_member_usage_limit_reached",
),
] {
headers.insert(name, http::HeaderValue::from_static(value));
}
let body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
body: Some(body),
}));
let CodexErrorDetails::UsageLimitReached(usage_limit) = err.details() else {
panic!("expected CodexErrorDetails::UsageLimitReached, got {err:?}");
};
assert_eq!(
usage_limit.rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
);
let snapshot = usage_limit
.rate_limits
.as_ref()
.expect("usage limit snapshot");
assert_eq!(snapshot.limit_id.as_deref(), Some(expected_limit_id));
assert_eq!(
snapshot.rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
);
}这证明 429 的 header 诊断会同时进入错误详情和快照;它不证明客户端会自动等待 reset,也不证明所有 429 都有 usage-limit body。
可以在 codex-rs/ 工作区运行:
cargo test -p codex-api parse_all_rate_limits_reads_all_limit_families
cargo test -p codex-api parse_all_rate_limits_includes_default_codex_snapshot
cargo test -p codex-core set_rate_limits_retains_previous_credits
cargo test -p codex-api map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot11. 配额定位
- 复述一条 HTTP header 快照和一条 WebSocket
codex.rate_limits事件分别经过哪些函数,在哪一步汇合为ResponseEvent::RateLimits。 - 给定一个新 snapshot:
limit_id = None、credits = None、plan_type = None,说明 Session merge 后哪些值来自旧快照,哪些值来自新快照。 - 只读定位:如果 UI 没有重复刷新,但 Session 已经收到了多个限额事件,应检查
turn.rs的should_emit_token_count和send_token_count_event,而不是修改 header parser。
说明: 文中的测试只验证客户端解析、归并和协议投影;它们不能证明服务端配额计算、跨设备一致性或 UI 的最终排版。
12. 技术边界
这些测试覆盖:HTTP headers 与 WebSocket 事件都能投影为 RateLimitSnapshot;默认和额外限制族有不同过滤规则;Session 对稀疏字段执行保留式 merge;Core 延迟到安全收口点发送 TokenCountEvent;429 的触达类型会附加到 usage-limit 错误。
本文未覆盖服务端限额计算、快照的新鲜度、多个限制族在所有前端中的展示策略、reset 时间到达后的主动刷新,以及未来协议字段新增时的 merge 语义。
下一篇将进入 HTTP Client 中间件,继续追踪 headers、代理、TLS、超时和响应体限制如何在这些模型请求之前生效。
