mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-15 00:06:24 +00:00
codex: persist quota verification
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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 存储;否则将其记录为双方未使用字段。
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct Account {
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub status: AccountStatus,
|
||||
pub quota_limit_reached: bool,
|
||||
pub quota_verify_required: bool,
|
||||
pub quota_cooldown_until: Option<DateTime<Utc>>,
|
||||
pub cloudflare_cooldown_until: Option<DateTime<Utc>>,
|
||||
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,
|
||||
|
||||
@@ -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::<i64, _>("quota_limit_reached") != 0,
|
||||
quota_verify_required: row.get::<i64, _>("quota_verify_required") != 0,
|
||||
quota_cooldown_until: parse_optional_rfc3339(
|
||||
row.get::<Option<String>, _>("quota_cooldown_until"),
|
||||
)?,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user