From 8780efd461b643f4ede80d1077fb6f85c242684e Mon Sep 17 00:00:00 2001 From: zyycn Date: Mon, 15 Jun 2026 10:02:53 +0800 Subject: [PATCH] codex: persist quota verification Co-authored-by: Codex --- docs/OPENAI_CHAIN_PARITY_REAUDIT.md | 6 ++++-- src/codex/accounts/model.rs | 2 ++ src/codex/accounts/repository/accounts.rs | 2 ++ src/codex/accounts/service/import.rs | 1 + src/codex/accounts/service/pool_sync.rs | 1 + src/platform/storage/schema.sql | 1 + tests/codex_accounts/repository.rs | 21 +++++++++++++++++++++ tests/platform/storage_schema.rs | 23 +++++++++++++++++++++++ 8 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md index 0ddb31da4..47fad5cf5 100644 --- a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md +++ b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md @@ -470,6 +470,7 @@ Rust 证据: - `src/codex/accounts/cookies/repository.rs:70-102` 构造 Cookie header 时按 domain 过滤并跳过过期 cookie;`delete_account_cookies()` 支持按账号清理。 - 本轮修正后,`src/codex/accounts/cookies/repository.rs` 提供 `cleanup_expired(now)`,会按现有 RFC2822/RFC3339 过期解析规则持久删除过期 cookie;`src/codex/tasks/cookie_cleanup.rs` 每 5 分钟运行一次清理,并在 `src/runtime/tasks/coordinator.rs` 中随后台任务启动。 - `src/platform/storage/schema.sql:68-83` `account_usage` 已持久化 `window_request_count`、`window_input_tokens`、`window_output_tokens`、`window_cached_tokens`、`window_started_at`、`window_reset_at`、`limit_window_seconds`、`last_used_at`。 +- 本轮修正后,`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/accounts.rs:329-562` list pool accounts 时会把持久化 usage/window 字段恢复到运行时 `Account`。 @@ -497,17 +498,18 @@ Rust 证据: - account retry decision 的失败请求持久 request_count 已对齐原版 release 语义:进入 429/402/403/401/model unsupported/Cloudflare 等账号 fallback/hold 处理时会写入一次无 token 的 request attempt;`v1_responses_should_mark_quota_exhausted_after_402_and_retry_next_account` 覆盖 402 fallback 失败账号计数。 - 非 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` 和启动恢复路径;`sqlite_schema_should_persist_quota_verify_required_flag` 与 `account_repository_should_restore_quota_verify_required_into_runtime_pool_accounts` 覆盖该行为。 未完全对齐: - image_generation usage 未对齐。原版记录 image input/output tokens、image request success/failure 和对应窗口计数;Rust `TokenUsage` 只有 input/output/cached/total,没有解析或持久化 `tool_usage.image_gen`。 -- dirty quota verification 缺失。原版离线 reset cachedQuota 后设置 `quotaVerifyRequired`,下一次请求会先调用 `/usage` 验证;Rust 只在本地窗口到期后清除 cooldown/推进窗口,没有等价 dirty 标记和请求前最多 5 次 upstream usage 校验。 +- dirty quota verification 请求前校验尚未对齐。状态字段已经持久化,但 Rust 还未在本地 quota reset 后设置 dirty 标记,也未在下一次请求前最多 5 次调用 `/usage` 验证 dirty 账号。 - Cookie domain/path 语义不同。原版 CookieJar 是 per-account 简单 map,不按 domain/path 过滤;Rust 按 domain/path 存储和匹配。对 `chatgpt.com` 主链路更严格,但不是原版逐字行为。 - quota window reset 细节不完全一致。原版 reset 后设置 `window_counters_reset_at` 并可能标记 quotaVerifyRequired;Rust 设置 `window_started_at`,没有 `window_counters_reset_at` 字段,也不触发 quotaVerifyRequired。 - passive header 文档提到 `x-codex-active-limit`,原版和 Rust 当前都没有实际解析该 header;如果 Codex Desktop 当前依赖 active-limit,这两边都需要重新确认。 缺口/后续动作: - 补 `TokenUsage` 的 image_generation 字段与数据库/运行时窗口字段,或明确 Rust 不支持 image_generation usage 统计。 -- 增加 dirty quota verification 机制:窗口离线 reset 后标记需要校验,请求前调用 usage endpoint,失败时保留 dirty 标记并限制放大次数。 +- 继续补 dirty quota verification 运行时机制:窗口离线 reset 后标记需要校验,请求前调用 usage endpoint,失败时保留 dirty 标记并限制放大次数。 - 明确 cookie domain/path 精细化是 Rust 新架构选择还是需要回到原版 per-account map;如果保留,应补 domain/path 行为测试。 - 若 OpenAI/Codex 当前返回 `x-codex-active-limit`,补解析和 quota_json 存储;否则将其记录为双方未使用字段。 diff --git a/src/codex/accounts/model.rs b/src/codex/accounts/model.rs index 6ff9ad0da..f29964d7a 100644 --- a/src/codex/accounts/model.rs +++ b/src/codex/accounts/model.rs @@ -24,6 +24,7 @@ pub struct Account { pub access_token_expires_at: Option>, pub status: AccountStatus, pub quota_limit_reached: bool, + pub quota_verify_required: bool, pub quota_cooldown_until: Option>, pub cloudflare_cooldown_until: Option>, pub request_count: u64, @@ -53,6 +54,7 @@ impl Account { access_token_expires_at: Some(Utc::now() + Duration::hours(1)), status, quota_limit_reached: false, + quota_verify_required: false, quota_cooldown_until: None, cloudflare_cooldown_until: None, request_count: 0, diff --git a/src/codex/accounts/repository/accounts.rs b/src/codex/accounts/repository/accounts.rs index c02be35b5..525857e34 100644 --- a/src/codex/accounts/repository/accounts.rs +++ b/src/codex/accounts/repository/accounts.rs @@ -324,6 +324,7 @@ select added_at, updated_at, quota_limit_reached, + quota_verify_required, quota_cooldown_until, cloudflare_cooldown_until, coalesce(account_usage.request_count, 0) as usage_request_count, @@ -529,6 +530,7 @@ impl AccountRepository { access_token_expires_at: stored.access_token_expires_at, status: stored.status, quota_limit_reached: row.get::("quota_limit_reached") != 0, + quota_verify_required: row.get::("quota_verify_required") != 0, quota_cooldown_until: parse_optional_rfc3339( row.get::, _>("quota_cooldown_until"), )?, diff --git a/src/codex/accounts/service/import.rs b/src/codex/accounts/service/import.rs index 974bb869d..204e19cd6 100644 --- a/src/codex/accounts/service/import.rs +++ b/src/codex/accounts/service/import.rs @@ -196,6 +196,7 @@ impl AccountService { access_token_expires_at, status, quota_limit_reached: false, + quota_verify_required: false, quota_cooldown_until: None, cloudflare_cooldown_until: None, request_count: 0, diff --git a/src/codex/accounts/service/pool_sync.rs b/src/codex/accounts/service/pool_sync.rs index a32d09486..e074881e9 100644 --- a/src/codex/accounts/service/pool_sync.rs +++ b/src/codex/accounts/service/pool_sync.rs @@ -52,6 +52,7 @@ pub(super) fn pool_account_from_stored(account: StoredAccount) -> Account { access_token_expires_at: account.access_token_expires_at, status: account.status, quota_limit_reached: false, + quota_verify_required: false, quota_cooldown_until: None, cloudflare_cooldown_until: None, request_count: 0, diff --git a/src/platform/storage/schema.sql b/src/platform/storage/schema.sql index 881d21af4..fbdfb5823 100644 --- a/src/platform/storage/schema.sql +++ b/src/platform/storage/schema.sql @@ -55,6 +55,7 @@ create table if not exists accounts ( quota_json text, quota_fetched_at text, quota_limit_reached integer not null default 0 check (quota_limit_reached in (0, 1)), + quota_verify_required integer not null default 0 check (quota_verify_required in (0, 1)), quota_cooldown_until text, cloudflare_cooldown_until text, added_at text not null, diff --git a/tests/codex_accounts/repository.rs b/tests/codex_accounts/repository.rs index 46cbfea2b..e91dd58d6 100644 --- a/tests/codex_accounts/repository.rs +++ b/tests/codex_accounts/repository.rs @@ -322,6 +322,27 @@ 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_should_restore_quota_verify_required_into_runtime_pool_accounts() { + 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([34u8; 32]); + let repo = AccountRepository::new(pool.clone(), secret_box.clone()); + + seed_repo_account(&pool, &secret_box, "acct_a", "2026-06-11T00:00:00Z").await; + sqlx::query("update accounts set quota_verify_required = 1 where id = ?") + .bind("acct_a") + .execute(&pool) + .await + .unwrap(); + + let account = repo.list_pool_accounts().await.unwrap().remove(0); + + assert!(account.quota_verify_required); +} + #[tokio::test] async fn account_repository_should_exclude_refresh_lease_until_expiry() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/platform/storage_schema.rs b/tests/platform/storage_schema.rs index 7aaacef75..29ca4e159 100644 --- a/tests/platform/storage_schema.rs +++ b/tests/platform/storage_schema.rs @@ -48,6 +48,23 @@ async fn sqlite_schema_should_persist_account_usage_window_columns() { ); } +#[tokio::test] +async fn sqlite_schema_should_persist_quota_verify_required_flag() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("test.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + + let row: Option<(String,)> = sqlx::query_as( + "select name from pragma_table_info('accounts') where name = 'quota_verify_required'", + ) + .fetch_optional(&pool) + .await + .unwrap(); + + assert_eq!(row, Some(("quota_verify_required".to_string(),))); +} + #[tokio::test] async fn sqlite_schema_should_persist_session_affinity_function_call_ids() { let dir = tempfile::tempdir().unwrap(); @@ -169,9 +186,15 @@ async fn sqlite_schema_should_reject_non_boolean_flags() { ) .execute(&pool) .await; + let quota_verify_result = sqlx::query( + "insert into accounts (id, access_token_cipher, status, quota_verify_required, added_at, updated_at) values ('acct_verify_bad', 'cipher', 'active', 2, '2026-06-14T00:00:00Z', '2026-06-14T00:00:00Z')", + ) + .execute(&pool) + .await; assert!(api_key_result.is_err()); assert!(account_result.is_err()); + assert!(quota_verify_result.is_err()); } #[tokio::test]