codex: preserve usage windows

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-15 11:15:53 +08:00
co-authored by Codex
parent 3ae55c994c
commit aa060bfec4
5 changed files with 92 additions and 13 deletions
+8 -4
View File
@@ -30,7 +30,7 @@ Rust 仓库:`/home/zyy/Codes/codex-proxy-rs`
- `/v1/responses` 请求构造仍存在非 `/v1` alias route、`use_websocket:false` 本地扩展等差异;`/v1/responses/review` 强制 review subagent 和 `/v1/responses/compact` 已补齐。
- response 转换和错误恢复仍缺最终错误外壳对齐;tuple schema request conversion/reconvert、streaming premature close 合成、reasoning replay、implicit resume failure restore、`response.failed`/`error` 的 429/402/403 分类、账号 fallback、`previous_response_not_found`/unanswered function call strip-and-retry、5xx same-account retry、CF path-block 404、model unsupported fallback、401 token invalid fallback 已补齐。
- session affinity 的显式续链、implicit resume 主路径、implicit resume failure restore、reasoning replay cache、function call continuation 预检、variant_hash 基础算法、variant identity 和 OpenAI chat `user` conversation identity 已接入。
- rate-limit/usage/cookie 持久化主路径已接近;dirty quota verification、失败请求持久计数、image_generation usage、Cookie domain/path 语义和 active-limit header 复核已补齐,剩余差异集中在 quota window reset 细节。
- rate-limit/usage/cookie 持久化主路径已接近;dirty quota verification、失败请求持久计数、image_generation usage、Cookie domain/path 语义、active-limit header 复核和 quota window reset 细节已补齐。
明确不纳入缺口:IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池。
@@ -48,7 +48,7 @@ Rust 仓库:`/home/zyy/Codes/codex-proxy-rs`
| 6 | WebSocket 上游链路与连接池 | 已完成 | 部分对齐 |
| 7 | response/SSE/WS frame 转换链路 | 已完成 | 部分对齐 |
| 8 | fallback、retry、错误分类 | 已完成 | 部分对齐 |
| 9 | rate-limit、usage、quota、cookie 持久化 | 已完成 | 部分对齐 |
| 9 | rate-limit、usage、quota、cookie 持久化 | 已完成 | 已对齐 |
| 10 | session affinity、implicit resume、reasoning replay | 已完成 | 部分对齐 |
## 1. 代码证据索引与审计边界
@@ -464,6 +464,7 @@ Rust 证据:
- `src/auth/account-registry.ts:215-266` 429 会合成或更新 cachedQuota.rate_limit,且不会缩短已知更晚 reset_at;`countRequest` 时同步增加 request_count/window_request_count。
- `src/auth/account-registry.ts:414-480` release 时记录 request_count、token usage、cached tokens、image_generation tokens、empty response、window counters。
- `src/auth/account-registry.ts:520-584` window 到期会重置窗口计数并推进下一窗口;cachedQuota 离线 reset 后会设置 `quotaVerifyRequired=true`。
- `src/auth/account-registry.ts:520-545` 手动 reset usage 会清空累计和窗口计数,但保留 `window_reset_at` 与 `limit_window_seconds`,并用 `window_counters_reset_at` 记录本次 reset 时间。
- `src/routes/shared/proxy-handler.ts:88-139` 在账号被 dirty quota 标记时,请求进入前最多做 5 次 `/usage` 校验,避免本地窗口推断把仍然限流的账号重新放回池。
Rust 证据:
@@ -476,6 +477,7 @@ Rust 证据:
- 本轮修正后,`src/platform/storage/schema.sql` 的 `accounts.quota_verify_required` 可持久化本地 quota reset 后的 dirty verification 标记,并通过 `AccountRepository::list_pool_accounts()` 恢复到运行时 `Account`。
- `src/codex/accounts/repository/usage.rs:12-44` `record_usage()` 会累计 total/window request 和 token 计数,并设置 `last_used_at`。
- `src/codex/accounts/repository/usage.rs:63-99` `sync_rate_limit_window()` 会在 reset_at 漂移超过窗口 50% 或默认 3600 秒时重置窗口计数,否则只同步 reset_at/limit_window_seconds。
- 本阶段修正后,`src/codex/accounts/repository/usage.rs` 的手动 reset usage 会保留既有 `window_reset_at` 与 `limit_window_seconds`,并在存在窗口边界时把 `window_started_at` 更新为本次 reset 时间;`src/codex/accounts/pool.rs` 的运行时 reset 同步保留窗口边界。
- `src/codex/accounts/repository/accounts.rs:329-562` list pool accounts 时会把持久化 usage/window 字段恢复到运行时 `Account`。
- `src/codex/gateway/transport/rate_limits.rs:58-144` rate-limit header 与 `codex.rate_limits` event 解析规则基本对应原版,并在 passive quota 缺 credits 时保留已有 credits。
- 2026-06-15 复核 `x-codex-active-limit`:Rust 全仓除本文档外没有 active-limit 代码引用;`src/codex/gateway/transport/rate_limits.rs` 与原版一致,只解析 primary、secondary 和 code_review/review header family。
@@ -491,6 +493,7 @@ Rust 证据:
- 本阶段修正后,`src/codex/gateway/transport/usage_events.rs` 的 `TokenUsage` 已增加 `image_input_tokens/image_output_tokens`,`extract_usage()` 和 `extract_sse_usage()` 会从 `response.tool_usage.image_gen` 读取图片工具 token,且 `total_tokens` 仍只表示 host-model token,符合原版“图片工具单独计费”的语义。
- 本阶段修正后,`src/platform/storage/schema.sql`、`src/codex/accounts/repository/usage.rs`、`src/codex/accounts/repository/accounts.rs` 和运行时 `Account` 已加入 total/window image token 与 image request success/failure 字段;`src/codex/serving/dispatch/usage.rs` 按请求是否声明 `image_generation` tool 和 `image_output_tokens > 0` 记录成功或失败。
- `tests/codex_accounts/repository.rs:230-326` 覆盖 window usage 持久化与恢复。
- 本阶段新增/扩展 `tests/codex_accounts/repository.rs::account_repository_reset_usage_should_preserve_rate_limit_window_metadata` 和 `tests/admin/accounts/cookies_quota.rs::admin_account_reset_usage_should_clear_local_counters_and_pool_last_used`,覆盖手动 reset usage 清空计数但不丢失持久层和运行时池的 rate-limit window metadata。
- `tests/codex_serving/responses_http_sse.rs:279-350` 覆盖 passive rate-limit headers 缓存 quota、窗口字段和 cooldown。
- `tests/codex_accounts/pool_scheduling.rs:150-466` 覆盖窗口过期重置、least_used 的 quota/window/request_count 排序和 runtime request count。
@@ -510,14 +513,15 @@ Rust 证据:
- 非 account retry decision 的最终失败请求持久 request_count 已对齐到原版 release 语义:未进入账号 fallback/hold 的最终错误会写入一次无 token 的 request attempt;`v1_responses_should_record_request_count_when_5xx_retries_are_exhausted` 覆盖 5xx 内部重试 3 次但持久 request_count 只计 1 次。
- transport error 的最终失败请求持久 request_count 已有覆盖;`v1_responses_should_record_request_count_when_http_transport_fails` 证明 HTTP transport 失败也会按原版 release 语义写入一次无 token 的 request attempt。
- dirty quota verification 已对齐原版主语义:`quota_verify_required` 进入 schema、runtime `Account` 和启动恢复路径;本地解除 quota 限制时会把账号标成 dirty;请求前会用 upstream `/usage` 校验 dirty 账号,仍限流时释放并切换账号,最多 5 次,校验失败时保留 dirty 标记并继续当前账号。
- quota window reset 已对齐原版语义:窗口自然过期、passive header reset 漂移和手动 reset usage 都会清空窗口计数;Rust 使用 `window_started_at` 表达当前窗口计数开始/重置时间,而不是再增加一个重复的 `window_counters_reset_at` 字段。手动 reset usage 现在与原版一致保留 `window_reset_at` 和 `limit_window_seconds`,不会破坏后续 least_used 窗口排序。
- image_generation usage 已对齐原版主语义:`tool_usage.image_gen` token 与 host-model token 分开累计;请求声明 `tools: [{type: "image_generation"}]` 且 `image_output_tokens > 0` 记成功,否则记失败;total/window 字段都能持久化并恢复到运行时账号。覆盖测试包括 `extract_usage_reads_image_generation_tool_usage_separately`、`extract_sse_usage_should_read_completed_image_generation_tool_usage`、`sqlite_schema_should_persist_image_generation_usage_columns`、`account_repository_should_accumulate_image_generation_usage_counters`、`account_repository_should_restore_window_usage_into_runtime_pool_accounts`、`v1_responses_should_record_image_generation_usage_when_tool_succeeds`、`v1_responses_should_record_failed_image_generation_attempt_when_tool_has_no_output`。
- `x-codex-active-limit` 已确认不是当前对齐缺口:原版只在注释中提及,实际没有解析、存储或调度消费;Rust 保持相同未使用状态。若未来 OpenAI/Codex upstream 开始实际依赖该字段,应作为新需求同时重新审计原版与 Rust。
未完全对齐:
- quota window reset 细节不完全一致。原版 reset 后设置 `window_counters_reset_at`;Rust 设置 `window_started_at` 并触发 `quota_verify_required`,但没有 `window_counters_reset_at` 字段。
- 无。
缺口/后续动作:
- 继续复核 quota window reset:确认 Rust 的 `window_started_at + quota_verify_required` 是否足以替代原版 `window_counters_reset_at`,或需要新增字段来保存本地窗口重置时间。
- 无。
## 10. session affinity、implicit resume、reasoning replay
+1 -2
View File
@@ -194,8 +194,7 @@ impl AccountPool {
account.window_image_output_tokens = 0;
account.window_image_request_count = 0;
account.window_image_request_failed_count = 0;
account.window_started_at = None;
account.window_reset_at = None;
account.window_started_at = account.window_reset_at.map(|_| Utc::now());
true
}
+6 -2
View File
@@ -171,8 +171,11 @@ on conflict(account_id) do update set
window_image_output_tokens = 0,
window_image_request_count = 0,
window_image_request_failed_count = 0,
window_started_at = null,
window_reset_at = null,
window_started_at = case
when account_usage.window_reset_at is not null then ?
else null
end,
window_reset_at = account_usage.window_reset_at,
last_used_at = null";
const LIST_ACCOUNT_USAGE_AFTER_CURSOR_SQL: &str = r"
@@ -399,6 +402,7 @@ impl AccountUsageRepository {
pub async fn reset_account(&self, account_id: &str) -> AccountRepositoryResult<()> {
sqlx::query(RESET_ACCOUNT_USAGE_SQL)
.bind(account_id)
.bind(Utc::now().to_rfc3339())
.execute(&self.pool)
.await?;
Ok(())
+35 -4
View File
@@ -5,6 +5,7 @@ use axum::{
body::Body,
http::{Request, StatusCode},
};
use chrono::{Duration, Utc};
use serde_json::json;
use tokio::sync::Mutex;
use tower::ServiceExt;
@@ -266,10 +267,13 @@ async fn admin_account_reset_usage_should_clear_local_counters_and_pool_last_use
let (app, state, pool, _dir) =
admin_accounts_test_app("admin-account-reset-usage.sqlite", 30).await;
import_test_account(&app, "session_1", "acct_reset_usage").await;
let window_reset_at = Utc::now() + Duration::minutes(5);
sqlx::query(
"insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens, last_used_at) values (?, 7, 11, 13, 17, ?)",
"insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds, last_used_at) values (?, 7, 11, 13, 17, 5, 19, 23, 29, ?, ?, 300, ?)",
)
.bind("acct_reset_usage")
.bind("2026-06-12T12:30:00Z")
.bind(window_reset_at.to_rfc3339())
.bind("2026-06-12T12:00:00Z")
.execute(&pool)
.await
@@ -293,14 +297,40 @@ async fn admin_account_reset_usage_should_clear_local_counters_and_pool_last_use
let body = response_json(response).await;
assert_eq!(body["data"]["id"], "acct_reset_usage");
assert_eq!(body["data"]["reset"], true);
let usage: (i64, i64, i64, i64, Option<String>) = sqlx::query_as(
"select request_count, input_tokens, output_tokens, cached_tokens, last_used_at from account_usage where account_id = ?",
type ResetUsageRow = (
i64,
i64,
i64,
i64,
i64,
i64,
i64,
i64,
Option<String>,
Option<String>,
);
let usage: ResetUsageRow = sqlx::query_as(
"select request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_reset_at, last_used_at from account_usage where account_id = ?",
)
.bind("acct_reset_usage")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage, (0, 0, 0, 0, None));
assert_eq!(
usage,
(
0,
0,
0,
0,
0,
0,
0,
0,
Some(window_reset_at.to_rfc3339()),
None
)
);
let acquired = state
.services
.accounts
@@ -308,6 +338,7 @@ async fn admin_account_reset_usage_should_clear_local_counters_and_pool_last_use
.await
.unwrap();
assert_eq!(acquired.request_count, 1);
assert_eq!(acquired.window_reset_at, Some(window_reset_at));
assert_ne!(
acquired.last_used_at.as_deref(),
Some("2026-06-12T12:00:00Z")
+42 -1
View File
@@ -4,7 +4,10 @@ use secrecy::{ExposeSecret, SecretString};
use codex_proxy_rs::{
codex::accounts::{
model::AccountStatus,
repository::{AccountClaimsUpdate, AccountRepository, NewAccount, TokenUpdate, UsageDelta},
repository::{
AccountClaimsUpdate, AccountRepository, AccountUsageRepository, NewAccount,
TokenUpdate, UsageDelta,
},
},
platform::crypto::SecretBox,
platform::storage::db::connect_sqlite,
@@ -390,6 +393,44 @@ async fn account_repository_should_restore_window_usage_into_runtime_pool_accoun
assert_eq!(account.limit_window_seconds, Some(300));
}
#[tokio::test]
async fn account_repository_reset_usage_should_preserve_rate_limit_window_metadata() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("accounts.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let secret_box = SecretBox::new([35u8; 32]);
let repo = AccountRepository::new(pool.clone(), secret_box.clone());
let window_started_at = Utc::now() - Duration::minutes(10);
let window_reset_at = Utc::now() + Duration::minutes(5);
seed_repo_account(&pool, &secret_box, "acct_a", "2026-06-11T00:00:00Z").await;
sqlx::query(
"insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens, window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds, last_used_at) values (?, 9, 31, 9, 2, 7, 11, 13, 17, ?, ?, 300, ?)",
)
.bind("acct_a")
.bind(window_started_at.to_rfc3339())
.bind(window_reset_at.to_rfc3339())
.bind(Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
AccountUsageRepository::new(pool.clone())
.reset_account("acct_a")
.await
.unwrap();
let usage = repo.get_usage("acct_a").await.unwrap().unwrap();
assert_eq!(usage.request_count, 0);
assert_eq!(usage.window_request_count, 0);
assert_eq!(usage.window_reset_at, Some(window_reset_at));
assert_eq!(usage.limit_window_seconds, Some(300));
assert!(usage
.window_started_at
.is_some_and(|reset_at| { reset_at > window_started_at && reset_at <= Utc::now() }));
}
#[tokio::test]
async fn account_repository_should_restore_quota_verify_required_into_runtime_pool_accounts() {
let dir = tempfile::tempdir().unwrap();