From 672f6798a7d9c7e77fc2ae35d1873030a3259c22 Mon Sep 17 00:00:00 2001 From: zyycn Date: Fri, 26 Jun 2026 00:27:21 +0800 Subject: [PATCH] fix: align official quota windows Co-authored-by: Codex --- docs/official-codex-request-chain.md | 364 ++++++++++++++++ src/admin/accounts/routes.rs | 303 ++++++++++++- src/admin/accounts/service/quota.rs | 59 ++- src/admin/monitoring/dashboard.rs | 22 +- src/upstream/accounts/quota/mod.rs | 410 ++++++++++++------ src/upstream/protocol/events.rs | 150 +++++-- tests/admin/accounts/import_export.rs | 69 ++- tests/admin/accounts/list.rs | 52 ++- tests/admin/accounts/quota.rs | 43 +- .../dispatch/chat_upstream/usage_logging.rs | 5 +- tests/upstream/accounts/quota_refresh.rs | 123 +++++- tests/upstream/protocol/usage_rate_limits.rs | 14 +- web/src/api/modules/accounts.ts | 10 +- web/src/views/accounts/AccountsView.vue | 155 +++++-- .../components/AccountOverviewCard.vue | 44 +- 15 files changed, 1541 insertions(+), 282 deletions(-) create mode 100644 docs/official-codex-request-chain.md diff --git a/docs/official-codex-request-chain.md b/docs/official-codex-request-chain.md new file mode 100644 index 000000000..ee314d51e --- /dev/null +++ b/docs/official-codex-request-chain.md @@ -0,0 +1,364 @@ +# 官方 Codex 请求链路解包记录 + +本记录基于官方 macOS Codex Desktop 包解包结果,用于校准 `codex-proxy-rs` 的 Codex 上游请求实现。 + +- 官方包路径:`/tmp/codex-desktop-official/dmg-extract/Codex Installer/Codex.app` +- App 版本:`26.616.81150` +- Build:`4306` +- app.asar 解包目录:`/tmp/codex-desktop-official/app-asar` +- 原生二进制:`Contents/Resources/codex`,Mach-O arm64,当前 Linux 环境不能直接运行,只能用静态字符串和 JS bundle 取证。 + +## 总结 + +官方 Desktop 的主对话请求链路不是 webview 直接 `fetch /codex/responses`。webview 负责 UI、设置和额度展示;认证头、远端控制、真实 Codex 请求链路主要在 Electron main bundle 和 `Resources/codex` 原生二进制里。 + +当前可确认的官方形态: + +- 对话主通道同时存在 `responses_http` 和 `responses_websocket` 两类 transport。 +- WebSocket beta 头为 `OpenAI-Beta: responses_websockets=2026-02-06`。 +- 请求头会携带 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`。 +- 原生二进制包含 `x-client-request-id`、`x-oai-attestation`、`x-responsesapi-include-timing-metrics`、`x-codex-installation-id`、`x-codex-turn-state` 等请求相关字段。 +- 请求体/WS payload 关键字段包含 `model`、`instructions`、`input`、`parallel_tool_calls`、`previous_response_id`、`store`、`include`、`client_metadata`。 +- 官方响应处理以 `response.completed` 作为完成边界;二进制里有 `stream closed before response.completed` 和 `remote compaction v2 stream closed before response.completed`。 +- 官方 WebSocket 失败存在回退路径,二进制里有 `websocket connection is unavailable`、`Falling back from WebSockets to HTTPS transport.`、`try_switch_fallback_transport`。 +- Cloudflare/cookie 风控存在官方实现,二进制里有 `ChatGptCloudflareCookieStore`、`set_cookies`、`cookies`。 + +## 官方证据 + +### Electron main + +文件:`/tmp/codex-desktop-official/app-asar/.vite/build/main-dSxbxAhH.js` + +关键函数: + +- `v_({ action, appServerClient, desktopOriginator, headers, refreshToken })`:调用 `appServerClient.getAuthToken`。 +- `y_(headers, token, { desktopOriginator })`:写入认证和 surface 头。 +- `x_(token)`:解析 JWT 中 `https://api.openai.com/auth.chatgpt_account_id`。 +- `S_()`:生成 `Codex Desktop/ (; )`。 + +可确认请求头: + +- `Authorization: Bearer ` +- `ChatGPT-Account-Id: ` +- `originator: ` +- `User-Agent: Codex Desktop/ (; )` + +同一 bundle 还包含 remote control WebSocket: + +- path:`/codex/remote/control/client` +- remote control 同样通过 `getAuthHeaders` 获取认证头。 +- 解析入站头时同时接受 `ChatGPT-Account-Id` 和 `chatgpt-account-id`。 + +### 原生 codex 二进制 + +文件:`/tmp/codex-desktop-official/dmg-extract/Codex Installer/Codex.app/Contents/Resources/codex` + +`strings` 可见的请求链路证据: + +- `responses_websockets=2026-02-06` +- `OpenAI-Beta` +- `x-client-request-id` +- `x-oai-attestation` +- `x-responsesapi-include-timing-metrics` +- `/responses/compact` +- `x-codex-installation-id` +- `responses_http` +- `responses/responses` +- `responses_websocket` +- `websocket connection is unavailable` +- `x-codex-turn-state` +- `auth_manager_missing` +- `recovery_succeeded` +- `recovery_failed_permanent` +- `recovery_failed_transient` +- `stream closed before response.completed` +- `model` +- `instructions` +- `input` +- `parallel_tool_calls` +- `previous_response_id` +- `ResponsesWsRequest` +- `response.create` +- `store` +- `include` +- `client_metadata` + +这些是二进制字符串证据,不等价于完整源码控制流;但足以证明官方包中存在这些 transport、请求字段、恢复状态和完成边界。 + +### Webview 额度逻辑 + +相关文件: + +- `webview/assets/thread-context-inputs-B6tQCr7t.js` +- `webview/assets/use-rate-limit-DfBgdYGx.js` +- `webview/assets/rate-limit-rows-B5kE6YOz.js` +- `webview/assets/usage-settings-BMxGTDii.js` +- `webview/assets/codex-api-AUWtp9Y7.js` + +确认点: + +- `thread-context-inputs` 查询 `/wham/usage`,query key 为 `rate-limit-status`,失败 401/403/404 时返回 `null`。 +- `use-rate-limit` 把 `rate_limit.primary_window`、`secondary_window` 转成 `primary`、`secondary` bucket,并遍历 `additional_rate_limits`。每个 additional item 会按 `limit_name` 建一个独立 snapshot。 +- `rate-limit-rows` 将每个 snapshot 的 `primary` 和 `secondary` 按 `windowDurationMins` 从小到大排序为多行,5 小时、周、月分别映射成 `five-hour`、`weekly`、`monthly` key。 +- `usage-settings` 识别: + - `300` 分钟:`5 hour usage limit` + - `10080` 分钟:`Weekly usage limit` + - `1440` 分钟:`Daily usage limit` + - `30 * 1440` 分钟:`Monthly usage limit` +- 月限额优先使用 `spend_control.individual_limit`;没有该字段时才从 30d rate-limit row 中取。 +- `codex-api` 有 `/wham/rate-limit-reset-credits` 和 `/consume`,用于额度 reset credit 的读取/消费。 + +结论:5h 和 7d/周限额可以同时出现,通常是同一个 snapshot 的 `primary_window` 与 `secondary_window`。30d/月限额是单独抽出来展示;free 账号常见只有月/30d 限额。 + +#### 限额组合还原 + +官方 webview 不是只取一个“当前限额”。它先把 `/wham/usage` 归一成 snapshot 列表: + +- core snapshot:来自顶层 `rate_limit`,包含 `primary` 和 `secondary` 两个可选窗口。 +- additional snapshots:来自 `additional_rate_limits[]`,每个有 `limit_name` 的条目各自拥有 `primary` 和 `secondary`。 +- 月限额:展示层优先读取 `spend_control.individual_limit`;如果没有该字段,才在 core rows 里寻找 30d window。 +- workspace monthly:企业/workspace 路径还可能从 workspace monthly usage 单独构造一个 monthly row。 + +因此官方 UI 里出现的组合是合理的: + +- `5h + 7d`:通常是同一个 snapshot 的 primary/secondary 窗口,官方会显示成两行 short-term rows。 +- `30d`:如果来自 `spend_control.individual_limit`,官方会作为 monthly usage limit 单独显示,并把 core 里的 30d row 从通用 rows 里过滤掉。 +- `free`:常见只看到 monthly/30d,是因为短周期窗口可能不存在,或者月限额由 spend control 提供。 +- model/additional:例如 `gpt-5.3-codex-spark` 这类 additional limit 会按 `limit_name` 单独组成 snapshot,和 core 限额不是同一行。 + +官方阻断判断也不是单看 `rate_limit.limit_reached`:`rate_limit.allowed === false`、`credits` 不可用、`spend_control.reached === true` 都会进入“额度不可用”的判断路径。additional limit 的阻断是按对应 `limit_name` 独立判断,不能简单等同于整个账号不可用。 + +### 官方响应事件 + +文件: + +- `app-asar/.vite/build/worker.js` +- `app-asar/.vite/build/workspace-root-drop-handler-DYf1cfzL.js` + +可见的 OpenTelemetry/事件处理名单: + +- operation:`responses.create`、`chat.completions.create`、`embeddings.create` +- stream events:`response.created`、`response.in_progress`、`response.failed`、`response.completed`、`response.incomplete`、`response.queued` +- delta/item events:`response.output_text.delta`、`response.output_item.added`、`response.function_call_arguments.delta`、`response.function_call_arguments.done`、`response.output_item.done` + +官方 worker 会从流事件里收集 response id、model、timestamp、usage、finish reason、text 和 tool calls。 + +## 当前项目对齐 + +### Transport 选择 + +当前实现:`src/upstream/protocol/responses.rs` + +- `force_http_sse = true` -> `HttpSse` +- `previous_response_id.is_some()` -> `WebSocketRequired` +- `use_websocket = true` -> `WebSocketPreferred` +- 其他情况 -> `HttpSse` +- `WebSocketRequired` 不允许 HTTP SSE fallback。 + +这和官方二进制里 `previous_response_id`、`responses_websocket`、`try_switch_fallback_transport` 的证据方向一致。 + +### HTTP SSE + +当前实现:`src/upstream/transport/client.rs` + +- POST `/codex/responses` +- `Accept: text/event-stream` +- body 为 `CodexResponsesRequest` +- 非 2xx 转为 `CodexClientError::Upstream`,保留 status、body、`retry-after`、`set-cookie` +- 非流式读取完整 SSE 文本并提取 usage +- 流式返回 `bytes_stream` + +### WebSocket + +当前实现: + +- `src/upstream/transport/websocket.rs` 把 base URL 的 `/codex/responses` 转成 `wss://.../codex/responses` 或 `ws://.../codex/responses`。 +- `src/upstream/transport/client.rs` 复用同一套请求头构造 WebSocket opening headers。 +- WebSocket 请求会写 audit artifact,包含 opening snapshot 和 payload snapshot。 +- 如果启用了 pool,会按账号/session 复用连接。 + +当前 WebSocket fallback: + +- `WebSocketPreferred` 发生连接/transport 类错误时允许 fallback 到 HTTP SSE。 +- `WebSocketRequired` 不 fallback。 +- 上游明确 HTTP 状态错误不会被当作普通 WS 连接错误降级。 + +这对应官方二进制里的: + +- `websocket connection is unavailable` +- `Falling back from WebSockets to HTTPS transport.` +- `try_switch_fallback_transport` + +### 请求头 + +当前实现:`src/upstream/transport/client.rs` + +基础 header 来自 fingerprint: + +- `authorization` +- `chatgpt-account-id` +- `originator` +- `user-agent` +- `sec-ch-ua` +- fingerprint 默认 header 和 header order + +Responses 请求额外写入: + +- `content-type: application/json` +- `cookie` +- `accept: text/event-stream` +- `openai-beta: responses_websockets=2026-02-06` +- `x-openai-internal-codex-residency: us` +- `x-client-request-id` +- `x-codex-installation-id` +- `session_id` +- `x-codex-window-id` +- `x-codex-turn-state` +- `x-codex-turn-metadata` +- `x-codex-beta-features` +- `x-responsesapi-include-timing-metrics` +- `version` +- `x-codex-parent-thread-id` +- 条件写入 `x-openai-subagent` + +官方二进制还出现 `x-oai-attestation`。当前项目没有构造该头;仅凭静态字符串无法确认其生成条件、签名来源和是否每次请求都必需,不能用固定值或伪造值补齐。 + +### 请求携带的数据 + +当前请求体:`src/upstream/protocol/responses.rs` + +核心字段: + +- `model` +- `instructions` +- `input` +- `stream` +- `store` +- `reasoning` +- `tools` +- `tool_choice` +- `parallel_tool_calls` +- `text` +- `service_tier` +- `previous_response_id` +- `prompt_cache_key` +- `include` +- `client_metadata` + +不会序列化但影响 header/transport 的字段: + +- `use_websocket` +- `force_http_sse` +- `turn_state` +- `turn_metadata` +- `beta_features` +- `version` +- `include_timing_metrics` +- `codex_window_id` +- `parent_thread_id` + +OpenAI 入口支持从 body 或 header 读取这些 Codex 上下文字段:`src/proxy/openai/responses.rs`。 + +### SSE / WS 响应处理 + +当前实现: + +- `src/upstream/protocol/responses.rs` 将 SSE 收集为 `Completed`、`Failed`、`MissingCompleted`、`Empty`。 +- `response.output_text.delta` 会累积文本。 +- `response.completed` 是成功边界。 +- `error` 或 `response.failed` 是失败边界。 +- 缺少 `response.completed` 时返回 `MissingCompleted`。 + +WebSocket 事件: + +- `src/upstream/protocol/websocket.rs` 会跳过内部 `codex.rate_limits` 事件,不直接下发给 OpenAI 客户端。 +- 对 `response.completed`、`response.created`、`response.output_text.delta` 和各类 output item 做官方形状校验。 +- `src/upstream/protocol/events.rs` 解析 `codex.rate_limits`,转换成 `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-code-review-*` 等 rate-limit header pairs。 + +调度层: + +- `src/proxy/dispatch/responses.rs` 在非流式和流式完成时记录 usage、event log、transport、rateLimitHeaders。 +- live stream 结束后再次按 `response.completed` 判定完成状态;未完成会记录 `missing_completed`。 +- live WebSocket 捕获到的 rate-limit 更新和 turn-state 更新会在 finalize 阶段合并。 + +### 风控和恢复 + +官方证据: + +- `ChatGptCloudflareCookieStore` +- `set_cookies` +- `cookies` +- `auth_manager_missing` +- `recovery_succeeded` +- `recovery_failed_permanent` +- `recovery_failed_transient` + +当前实现: + +- `src/proxy/dispatch/cloudflare.rs` 从 `chatgpt.com` cookie store 为 `/codex/responses`、`/codex/responses/compact`、`/codex/usage` 拼接 cookie。 +- 捕获上游 `set-cookie` 并按账号持久化。 +- 403 body 命中 `cf-mitigated`、`cf-chl-bypass`、`_cf_chl`、`cf_chl`、`attention required`、`just a moment` 时进入 Cloudflare challenge cooldown。 +- 404 空 body 作为 path-block,立即删除账号 cookie,多次后禁用账号。 +- 调度层会在 Cloudflare challenge/path-block 后换号或返回明确错误。 + +### 额度和限流 + +官方 webview 使用 `/wham/usage` 获取额度;原生二进制有 `parse_rate_limit_event` 和 `codex.rate_limits`。 + +当前实现: + +- `/wham/usage` 响应归一化在 `src/upstream/accounts/quota/mod.rs`。 +- `rate_limit.primary_window` / `secondary_window` -> `snapshots[].primary` / `snapshots[].secondary` +- `additional_rate_limits[]` -> 独立 `snapshots[]`,`source = "additional"`,保留 `limit_name` / `metered_feature` +- `spend_control.individual_limit` -> `monthly_limit`,优先作为月限额展示;没有该字段时才从 core 30d window 生成 `monthly_limit` +- WebSocket 内部 `codex.rate_limits` 事件由 `src/upstream/protocol/events.rs` 解析。 + +本项目对齐官方后的展示约定: + +- `5小时限额` 和 `周限额` 归入 `shortTerm` group,可以同时出现。 +- `月限额` 归入 `monthly` group,优先来自 `spend_control.individual_limit`,再回退到 30d rate-limit window。 +- additional/model 限额不覆盖 core 限额,作为独立 window 行保留;如果同一个 additional limit 同时有 5h/7d,也会拆成两行。 +- WebSocket `codex.rate_limits` 被动刷新只携带短周期窗口时,输出同样的 `snapshots` 结构,并保留已有 `monthly_limit`、`credits`、`spend_control` 和 additional snapshots,避免把 `/wham/usage` 拿到的月限额或 additional 限额冲掉。 + +### 真实数据验证 + +2026-06-25 使用本地 `.runtime` active free 账号调用 `GET /api/admin/accounts/quota`,经项目真实解密、请求官方 `/wham/usage`、归一化并落库。 + +官方实时响应形态: + +- `plan_type = free` +- `rate_limit.primary_window.limit_window_seconds = 2592000` +- `rate_limit.primary_window.used_percent = 6` +- `rate_limit.secondary_window = null` +- `spend_control.individual_limit = null` +- `credits.has_credits = false` + +项目归一化结果: + +- `snapshots[0].source = "core"` +- `snapshots[0].primary.window_minutes = 43200` +- `snapshots[0].secondary = null` +- `monthly_limit.source = "rate_limit"` +- 管理端账号列表返回 `quota.windows[0].group = "monthly"`、`labelDisplay = "月限额"`、`windowSeconds = 2592000` + +结论:本地真实 free 账号确认为 30d/月限额单独展示。当前本地没有 active 的 5h+7d 账号可实时验证组合返回;该组合路径由官方 bundle 证据和项目测试覆盖。 + +## 当前差距 + +- `x-oai-attestation` 只确认官方二进制里存在,未确认生成算法、触发条件、签名材料和失败行为;当前项目不实现该头。 +- 原生二进制不能在当前 Linux 环境执行,无法做动态断点或真实 app-server 调用链追踪。 +- 官方包里没有 source map,JS bundle 为压缩产物;函数名和局部变量名不能视为稳定 API。 +- WebSocket 降级策略目前按现有项目实现理解为连接/transport 错误可降级,显式上游状态错误不降级;官方二进制字符串能证明有降级路径,但不能单独证明完整条件矩阵。 + +## 实现建议 + +短期不建议伪造 `x-oai-attestation`。如果后续真实链路出现必须 attestation 的 403/401,再单独做动态取证或抓官方运行态请求,确认该头来源后实现。 + +当前项目更应该保持: + +- `responses_websockets=2026-02-06` beta 头。 +- `x-client-request-id`、`x-codex-installation-id`、`x-codex-window-id`、`x-codex-turn-state`、`x-codex-turn-metadata` 的透传。 +- `previous_response_id` 强制 WebSocket。 +- `WebSocketPreferred` 可降级 HTTP SSE,`WebSocketRequired` 不降级。 +- `codex.rate_limits` 内部事件不下发给客户端,但用于刷新账号限额状态。 +- Cloudflare cookie 捕获、重放、challenge cooldown、path-block 清 cookie/禁用账号。 diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 02fe4a11d..9fdfe6db3 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -36,6 +36,9 @@ use crate::{ }; const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200; +const FIVE_HOUR_WINDOW_SECONDS: u64 = 18_000; +const WEEK_WINDOW_SECONDS: u64 = 604_800; +const MONTH_WINDOW_SECONDS: u64 = 2_592_000; // ============================================================================ // Query / Request types @@ -161,21 +164,28 @@ impl AdminAccountData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AdminAccountQuotaData { + refreshed_at_display: String, + windows: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminAccountQuotaWindowData { + key: String, + group: String, + window_seconds: Option, + label_display: String, used_percent: Option, used_percent_display: String, reset_at_display: String, - refreshed_at_display: String, window_used_display: String, } impl Default for AdminAccountQuotaData { fn default() -> Self { Self { - used_percent: None, - used_percent_display: "-".to_string(), - reset_at_display: "-".to_string(), refreshed_at_display: "-".to_string(), - window_used_display: "-".to_string(), + windows: Vec::new(), } } } @@ -1024,19 +1034,61 @@ fn model_usage_data(usage: ModelUsageAggregate) -> AdminAccountModelUsageData { fn quota_data(quota_json: &str, fetched_at: Option>) -> AdminAccountQuotaData { let quota = serde_json::from_str::(quota_json).unwrap_or(Value::Null); - let used_percent = quota - .pointer("/rate_limit/used_percent") - .and_then(number_value) - .map(|value| value.clamp(0.0, 100.0)); - let reset_at = quota - .pointer("/rate_limit/reset_at") - .and_then(Value::as_i64) - .and_then(|value| DateTime::::from_timestamp(value, 0)); - let window_seconds = quota - .pointer("/rate_limit/limit_window_seconds") - .and_then(Value::as_u64); + let windows = quota_windows("a); AdminAccountQuotaData { + refreshed_at_display: china_relative_time(fetched_at, Utc::now()), + windows, + } +} + +fn quota_windows(quota: &Value) -> Vec { + let mut windows = Vec::new(); + + let has_monthly_limit = push_monthly_quota_window(&mut windows, quota.get("monthly_limit")); + if let Some(snapshots) = quota.get("snapshots").and_then(Value::as_array) { + for snapshot in snapshots { + push_snapshot_quota_windows(&mut windows, snapshot, has_monthly_limit); + } + } + + windows.sort_by_key(quota_window_sort_key); + windows +} + +fn push_monthly_quota_window( + windows: &mut Vec, + monthly_limit: Option<&Value>, +) -> bool { + let Some(monthly_limit) = monthly_limit.filter(|value| !value.is_null()) else { + return false; + }; + let used_percent = monthly_limit + .get("used_percent") + .and_then(number_value) + .map(|value| value.clamp(0.0, 100.0)); + let reset_at = monthly_limit + .get("reset_at") + .and_then(Value::as_i64) + .and_then(|value| DateTime::::from_timestamp(value, 0)); + let window_seconds = monthly_limit + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)) + .or(Some(MONTH_WINDOW_SECONDS)); + if used_percent.is_none() && reset_at.is_none() { + return false; + } + + let key = monthly_limit + .get("key") + .and_then(Value::as_str) + .unwrap_or("monthly"); + windows.push(AdminAccountQuotaWindowData { + key: quota_key_segment(key), + group: "monthly".to_string(), + window_seconds, + label_display: "月限额".to_string(), used_percent, used_percent_display: used_percent .map(format_percent) @@ -1045,8 +1097,210 @@ fn quota_data(quota_json: &str, fetched_at: Option>) -> AdminAccou .as_ref() .map(china_datetime) .unwrap_or_else(|| "-".to_string()), - refreshed_at_display: china_relative_time(fetched_at, Utc::now()), window_used_display: quota_window_used_display(reset_at, window_seconds), + }); + true +} + +fn push_snapshot_quota_windows( + windows: &mut Vec, + snapshot: &Value, + skip_core_monthly: bool, +) { + let source = snapshot + .get("source") + .and_then(Value::as_str) + .unwrap_or("quota"); + let source_key = snapshot_source_key(source, snapshot); + let label_prefix = snapshot_label(snapshot); + for role in ["primary", "secondary"] { + let Some(window) = snapshot.get(role).filter(|value| !value.is_null()) else { + continue; + }; + let window_seconds = window + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)); + if skip_core_monthly + && source == "core" + && window_seconds + .is_some_and(|seconds| quota_window_matches(seconds, MONTH_WINDOW_SECONDS)) + { + continue; + } + push_quota_window( + windows, + &source_key, + role, + label_prefix.as_deref(), + Some(window), + ); + } +} + +fn push_quota_window( + windows: &mut Vec, + source_key: &str, + role: &str, + label_prefix: Option<&str>, + window: Option<&Value>, +) { + let Some(window) = window.filter(|value| !value.is_null()) else { + return; + }; + let used_percent = window + .get("used_percent") + .and_then(number_value) + .map(|value| value.clamp(0.0, 100.0)); + let reset_at = window + .get("reset_at") + .and_then(Value::as_i64) + .and_then(|value| DateTime::::from_timestamp(value, 0)); + let window_seconds = window + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)); + if used_percent.is_none() && reset_at.is_none() && window_seconds.is_none() { + return; + } + let base_label = quota_window_label_display(window_seconds); + let label_display = label_prefix + .filter(|value| !value.trim().is_empty()) + .map(|value| format!("{} · {}", value.trim(), base_label)) + .unwrap_or(base_label); + + windows.push(AdminAccountQuotaWindowData { + key: unique_quota_window_key(windows, source_key, role, window_seconds), + group: quota_window_group(window_seconds).to_string(), + window_seconds, + label_display, + used_percent, + used_percent_display: used_percent + .map(format_percent) + .unwrap_or_else(|| "-".to_string()), + reset_at_display: reset_at + .as_ref() + .map(china_datetime) + .unwrap_or_else(|| "-".to_string()), + window_used_display: quota_window_used_display(reset_at, window_seconds), + }); +} + +fn snapshot_source_key(source: &str, snapshot: &Value) -> String { + let label = snapshot + .get("limit_name") + .and_then(Value::as_str) + .or_else(|| snapshot.get("metered_feature").and_then(Value::as_str)) + .unwrap_or(source); + format!("{}-{}", quota_key_segment(source), quota_key_segment(label)) +} + +fn snapshot_label(snapshot: &Value) -> Option { + let source = snapshot.get("source").and_then(Value::as_str); + if source == Some("core") { + return None; + } + let label = snapshot + .get("limit_name") + .and_then(Value::as_str) + .or_else(|| snapshot.get("metered_feature").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty())?; + if label.eq_ignore_ascii_case("codex") { + return None; + } + if is_review_limit_label(Some(label)) { + return Some("代码审查".to_string()); + } + Some(label.to_string()) +} + +fn unique_quota_window_key( + windows: &[AdminAccountQuotaWindowData], + source_key: &str, + role: &str, + window_seconds: Option, +) -> String { + let bucket = quota_window_key_part(window_seconds).unwrap_or(role); + let key = format!("{source_key}-{bucket}"); + if windows.iter().any(|window| window.key == key) { + format!("{key}-{role}") + } else { + key + } +} + +fn quota_window_key_part(window_seconds: Option) -> Option<&'static str> { + match window_seconds { + Some(seconds) if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { + Some("five-hour") + } + Some(seconds) if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => Some("weekly"), + Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => Some("monthly"), + _ => None, + } +} + +fn quota_key_segment(value: &str) -> String { + let mut segment = String::with_capacity(value.len()); + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + segment.push(ch.to_ascii_lowercase()); + } else if !segment.ends_with('-') { + segment.push('-'); + } + } + let segment = segment.trim_matches('-'); + if segment.is_empty() { + "quota".to_string() + } else { + segment.to_string() + } +} + +fn quota_window_sort_key(window: &AdminAccountQuotaWindowData) -> (u8, u64, String) { + let group_order = match window.group.as_str() { + "monthly" => 0, + "shortTerm" => 1, + _ => 2, + }; + ( + group_order, + window.window_seconds.unwrap_or(0), + window.key.clone(), + ) +} + +fn quota_window_group(window_seconds: Option) -> &'static str { + match window_seconds { + Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => "monthly", + Some(seconds) + if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) + || quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => + { + "shortTerm" + } + _ => "other", + } +} + +fn quota_window_matches(actual: u64, expected: u64) -> bool { + actual > 0 && actual.abs_diff(expected) <= expected / 20 +} + +fn quota_window_label_display(window_seconds: Option) -> String { + let Some(window_seconds) = window_seconds.filter(|seconds| *seconds > 0) else { + return "额度".to_string(); + }; + match window_seconds { + seconds if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { + "5小时限额".to_string() + } + seconds if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => "周限额".to_string(), + seconds if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => "月限额".to_string(), + seconds if seconds % 86_400 == 0 => format!("{}天限额", seconds / 86_400), + seconds if seconds % 3_600 == 0 => format!("{}小时限额", seconds / 3_600), + seconds => format!("{}分钟限额", seconds.div_ceil(60)), } } @@ -1091,6 +1345,21 @@ fn number_value(value: &Value) -> Option { value .as_f64() .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) + .filter(|value| value.is_finite()) +} + +fn is_review_limit_label(value: Option<&str>) -> bool { + let normalized = value + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_"); + normalized == "review" + || normalized == "code_review" + || normalized == "codex_review" + || normalized == "codex_code_review" + || normalized.contains("code_review") + || normalized.contains("codex_review") } fn nonnegative_i64_to_u64(value: i64) -> u64 { diff --git a/src/admin/accounts/service/quota.rs b/src/admin/accounts/service/quota.rs index 9560b1296..4e8f9dff3 100644 --- a/src/admin/accounts/service/quota.rs +++ b/src/admin/accounts/service/quota.rs @@ -70,15 +70,13 @@ impl AdminAccountService { "level": "exhausted" })); } else { - // 按阈值检查 used_percent。 - let mut check_threshold = |quota_key: &str, thresholds: &[u8]| { - let used_percent = quota - .get(quota_key) - .and_then(|v| v.get("used_percent")) - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); + for (quota_key, used_percent, role) in quota_warning_windows("a) { + let thresholds = match role { + "secondary" => &self.quota_thresholds.secondary, + _ => &self.quota_thresholds.primary, + }; for threshold in thresholds { - if used_percent >= u64::from(*threshold) { + if used_percent >= f64::from(*threshold) { warnings.push(serde_json::json!({ "accountId": snap.account_id, "email": snap.email, @@ -90,9 +88,7 @@ impl AdminAccountService { break; } } - }; - check_threshold("rate_limit", &self.quota_thresholds.primary); - check_threshold("secondary_rate_limit", &self.quota_thresholds.secondary); + } } } @@ -199,3 +195,44 @@ impl AdminAccountService { })) } } + +fn quota_warning_windows(quota: &serde_json::Value) -> Vec<(String, f64, &'static str)> { + let mut windows = Vec::new(); + if let Some(monthly) = quota.get("monthly_limit").filter(|value| !value.is_null()) { + if let Some(used_percent) = used_percent(monthly) { + windows.push(("monthly_limit".to_string(), used_percent, "primary")); + } + } + if let Some(snapshots) = quota.get("snapshots").and_then(serde_json::Value::as_array) { + for snapshot in snapshots { + let source = snapshot + .get("source") + .and_then(serde_json::Value::as_str) + .unwrap_or("quota"); + let limit_name = snapshot + .get("limit_name") + .and_then(serde_json::Value::as_str) + .unwrap_or(source); + for role in ["primary", "secondary"] { + let Some(window) = snapshot.get(role).filter(|value| !value.is_null()) else { + continue; + }; + if let Some(used_percent) = used_percent(window) { + windows.push((format!("{source}:{limit_name}:{role}"), used_percent, role)); + } + } + } + } + windows +} + +fn used_percent(value: &serde_json::Value) -> Option { + value + .get("used_percent") + .and_then(|value| { + value + .as_f64() + .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) + }) + .filter(|value| value.is_finite()) +} diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs index 2d6d19e12..1c1afcbbc 100644 --- a/src/admin/monitoring/dashboard.rs +++ b/src/admin/monitoring/dashboard.rs @@ -925,9 +925,27 @@ async fn account_quota_used_percent_by_id( fn quota_used_percent(quota_json: &str) -> Option { let quota = serde_json::from_str::(quota_json).ok()?; - quota - .pointer("/rate_limit/used_percent") + let mut values = Vec::new(); + if let Some(used_percent) = quota + .pointer("/monthly_limit/used_percent") .and_then(percent_value) + { + values.push(used_percent); + } + if let Some(snapshots) = quota.get("snapshots").and_then(Value::as_array) { + for snapshot in snapshots { + for role in ["primary", "secondary"] { + if let Some(used_percent) = snapshot + .get(role) + .and_then(|window| window.get("used_percent")) + .and_then(percent_value) + { + values.push(used_percent); + } + } + } + } + values.into_iter().max_by(f64::total_cmp) } fn percent_value(value: &Value) -> Option { diff --git a/src/upstream/accounts/quota/mod.rs b/src/upstream/accounts/quota/mod.rs index 31827e128..a93ba701a 100644 --- a/src/upstream/accounts/quota/mod.rs +++ b/src/upstream/accounts/quota/mod.rs @@ -1,148 +1,139 @@ //! 配额检查策略。 use chrono::{DateTime, Utc}; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; + +const MONTH_WINDOW_MINUTES: u64 = 43_200; +const MONTH_WINDOW_SECONDS: u64 = 2_592_000; /// 判断配额百分比是否达到阈值。 pub fn quota_reached(used_percent: f64, threshold: f64) -> bool { used_percent >= threshold } -/// 判断配额快照的主 rate-limit 是否已经触顶。 +/// 判断配额快照是否已经触顶。 pub fn quota_snapshot_limit_reached(quota: &Value) -> bool { - quota - .pointer("/rate_limit/limit_reached") - .and_then(Value::as_bool) - .unwrap_or(false) + core_snapshot_blocked(quota) + || spend_control_limit_reached(quota) + || monthly_limit_reached(quota) + || credits_exhausted(quota) } -/// 从配额快照读取主 rate-limit 的 UTC 重置时间。 +/// 从配额快照读取 UTC 重置时间。 pub fn quota_snapshot_reset_at(quota: &Value) -> Option> { - let reset_at = quota - .pointer("/rate_limit/reset_at") - .and_then(Value::as_i64) - .filter(|value| *value > 0)?; + let reset_at = if monthly_limit_reached(quota) || spend_control_limit_reached(quota) { + quota + .pointer("/monthly_limit/reset_at") + .and_then(positive_i64) + .or_else(|| blocking_core_bucket(quota).and_then(bucket_reset_at)) + } else { + blocking_core_bucket(quota).and_then(bucket_reset_at) + }?; DateTime::::from_timestamp(reset_at, 0) } -/// 从配额快照读取主 rate-limit 窗口大小。 +/// 从配额快照读取窗口大小。 pub fn quota_snapshot_limit_window_seconds(quota: &Value) -> Option { - quota - .pointer("/rate_limit/limit_window_seconds") - .and_then(Value::as_u64) - .filter(|value| *value > 0) + if monthly_limit_reached(quota) || spend_control_limit_reached(quota) { + return Some(MONTH_WINDOW_SECONDS); + } + blocking_core_bucket(quota) + .and_then(bucket_window_minutes) + .and_then(|minutes| minutes.checked_mul(60)) } /// 从 Codex usage 响应中提取持久化配额快照。 pub fn quota_from_usage(usage: &Value) -> Value { - let additional = usage - .get("additional_rate_limits") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let mut rate_limits_by_limit_id = Map::new(); - - for item in &additional { - let Some(limit_id) = item - .get("metered_feature") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - continue; - }; - let quota = quota_from_rate_limit(item.get("rate_limit")); - if quota.is_null() { - continue; - } - rate_limits_by_limit_id.insert( - limit_id.to_string(), - json!({ - "limit_id": limit_id, - "limit_name": item.get("limit_name").cloned().unwrap_or(Value::Null), - "allowed": quota.get("allowed").cloned().unwrap_or(Value::Null), - "limit_reached": quota.get("limit_reached").cloned().unwrap_or(Value::Null), - "used_percent": quota.get("used_percent").cloned().unwrap_or(Value::Null), - "remaining_percent": quota.get("remaining_percent").cloned().unwrap_or(Value::Null), - "reset_at": quota.get("reset_at").cloned().unwrap_or(Value::Null), - "limit_window_seconds": quota.get("limit_window_seconds").cloned().unwrap_or(Value::Null), - "secondary_rate_limit": secondary_quota_from_rate_limit(item.get("rate_limit")), - }), - ); + let mut snapshots = Vec::new(); + if let Some(snapshot) = quota_snapshot_from_rate_limit( + "core", + usage.get("rate_limit_name").and_then(trimmed_str), + usage.get("metered_feature").and_then(trimmed_str), + usage.get("rate_limit"), + ) { + snapshots.push(snapshot); } - let additional_review = additional.iter().find(|item| { - is_review_limit_id(item.get("metered_feature").and_then(Value::as_str)) - || is_review_limit_id(item.get("limit_name").and_then(Value::as_str)) - }); - let code_review_rate_limit = match quota_from_rate_limit(usage.get("code_review_rate_limit")) { - Value::Null => { - quota_from_rate_limit(additional_review.and_then(|item| item.get("rate_limit"))) + if let Some(additional) = usage + .get("additional_rate_limits") + .and_then(Value::as_array) + { + for item in additional { + let Some(limit_name) = item.get("limit_name").and_then(trimmed_str) else { + continue; + }; + if let Some(snapshot) = quota_snapshot_from_rate_limit( + "additional", + Some(limit_name), + item.get("metered_feature").and_then(trimmed_str), + item.get("rate_limit"), + ) { + snapshots.push(snapshot); + } } - quota => quota, - }; + } + + let monthly_limit = monthly_limit_from_spend_control(usage.get("spend_control")) + .or_else(|| monthly_limit_from_snapshots(&snapshots)) + .unwrap_or(Value::Null); json!({ "plan_type": usage.get("plan_type").cloned().unwrap_or(Value::Null), - "rate_limit": quota_from_rate_limit(usage.get("rate_limit")), - "secondary_rate_limit": secondary_quota_from_rate_limit(usage.get("rate_limit")), - "code_review_rate_limit": code_review_rate_limit, - "rate_limits_by_limit_id": if rate_limits_by_limit_id.is_empty() { - Value::Null - } else { - Value::Object(rate_limits_by_limit_id) - }, + "snapshots": snapshots, + "monthly_limit": monthly_limit, "credits": normalize_quota_credits(usage.get("credits")), + "spend_control": normalize_spend_control(usage.get("spend_control")), }) } -fn quota_from_rate_limit(rate_limit: Option<&Value>) -> Value { +fn quota_snapshot_from_rate_limit( + source: &str, + limit_name: Option<&str>, + metered_feature: Option<&str>, + rate_limit: Option<&Value>, +) -> Option { let Some(rate_limit) = rate_limit.filter(|value| !value.is_null()) else { - return Value::Null; + return None; }; - let primary = rate_limit.get("primary_window"); - let used_percent = primary - .and_then(|window| window.get("used_percent")) - .cloned() - .unwrap_or(Value::Null); - json!({ + let primary = window_from_rate_limit(rate_limit.get("primary_window")); + let secondary = window_from_rate_limit(rate_limit.get("secondary_window")); + if primary.is_null() && secondary.is_null() { + return None; + } + Some(json!({ + "source": source, + "limit_name": limit_name, + "metered_feature": metered_feature, "allowed": rate_limit.get("allowed").cloned().unwrap_or(Value::Null), "limit_reached": rate_limit.get("limit_reached").cloned().unwrap_or(Value::Null), - "used_percent": used_percent, - "remaining_percent": remaining_percent(primary.and_then(|window| window.get("used_percent"))), - "reset_at": primary.and_then(|window| window.get("reset_at")).cloned().unwrap_or(Value::Null), - "limit_window_seconds": primary.and_then(|window| window.get("limit_window_seconds")).cloned().unwrap_or(Value::Null), - }) + "blocked": rate_limit_blocked(rate_limit) + || window_limit_reached(&primary) + || window_limit_reached(&secondary), + "primary": primary, + "secondary": secondary, + })) } -fn secondary_quota_from_rate_limit(rate_limit: Option<&Value>) -> Value { - let Some(secondary) = rate_limit - .and_then(|rate_limit| rate_limit.get("secondary_window")) - .filter(|value| !value.is_null()) - else { +fn window_from_rate_limit(window: Option<&Value>) -> Value { + let Some(window) = window.filter(|value| !value.is_null()) else { return Value::Null; }; - let used_percent = secondary + let used_percent = window .get("used_percent") - .cloned() - .unwrap_or(Value::Null); - let limit_reached = secondary - .get("used_percent") - .and_then(Value::as_f64) - .map(|used| used >= 100.0) - .map(Value::Bool) - .or_else(|| { - rate_limit - .and_then(|rate_limit| rate_limit.get("limit_reached")) - .cloned() - }) - .unwrap_or(Value::Null); + .and_then(number_value) + .unwrap_or(0.0) + .clamp(0.0, 100.0); + let window_minutes = window + .get("limit_window_seconds") + .and_then(number_value) + .filter(|seconds| *seconds > 0.0) + .map(|seconds| (seconds / 60.0).round() as u64); json!({ - "limit_reached": limit_reached, "used_percent": used_percent, - "remaining_percent": remaining_percent(secondary.get("used_percent")), - "reset_at": secondary.get("reset_at").cloned().unwrap_or(Value::Null), - "limit_window_seconds": secondary.get("limit_window_seconds").cloned().unwrap_or(Value::Null), + "remaining_percent": remaining_percent(used_percent), + "reset_at": window.get("reset_at").and_then(positive_i64), + "window_minutes": window_minutes, + "limit_reached": used_percent >= 100.0, }) } @@ -150,41 +141,206 @@ fn normalize_quota_credits(raw: Option<&Value>) -> Value { let Some(raw) = raw.filter(|value| !value.is_null()) else { return Value::Null; }; - let Some(balance) = raw - .get("balance") - .and_then(Value::as_str) - .and_then(|value| value.parse::().ok()) - .filter(|value| value.is_finite()) - else { - return Value::Null; - }; json!({ "has_credits": raw.get("has_credits").and_then(Value::as_bool).unwrap_or(false), "unlimited": raw.get("unlimited").and_then(Value::as_bool).unwrap_or(false), "overage_limit_reached": raw.get("overage_limit_reached").and_then(Value::as_bool).unwrap_or(false), - "balance": balance, + "balance": raw.get("balance").cloned().unwrap_or(Value::Null), }) } -fn remaining_percent(used_percent: Option<&Value>) -> Value { - let Some(used_percent) = used_percent.and_then(Value::as_f64) else { +fn normalize_spend_control(raw: Option<&Value>) -> Value { + let Some(raw) = raw.filter(|value| !value.is_null()) else { return Value::Null; }; - json!((100.0 - used_percent.clamp(0.0, 100.0)).round() as i64) + raw.clone() } -fn is_review_limit_id(value: Option<&str>) -> bool { - let normalized = value - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .replace(['-', ' '], "_"); - normalized == "review" - || normalized == "code_review" - || normalized == "codex_review" - || normalized == "codex_code_review" - || normalized.contains("code_review") - || normalized.contains("codex_review") +fn monthly_limit_from_spend_control(spend_control: Option<&Value>) -> Option { + let individual_limit = spend_control? + .get("individual_limit") + .filter(|value| !value.is_null())?; + let used_percent = individual_limit + .get("used_percent") + .and_then(number_value) + .unwrap_or(0.0) + .clamp(0.0, 100.0); + Some(json!({ + "key": "spend-control-monthly", + "source": "spend_control", + "used_percent": used_percent, + "remaining_percent": individual_limit + .get("remaining_percent") + .and_then(number_value) + .unwrap_or_else(|| remaining_percent(used_percent) as f64), + "reset_at": individual_limit.get("reset_at").and_then(positive_i64), + "window_minutes": MONTH_WINDOW_MINUTES, + "limit_reached": spend_control_limit_reached_value(spend_control.unwrap_or(&Value::Null), used_percent), + "used_credits": individual_limit.get("used").cloned().unwrap_or(Value::Null), + "limit_credits": individual_limit.get("limit").cloned().unwrap_or(Value::Null), + })) +} + +fn monthly_limit_from_snapshots(snapshots: &[Value]) -> Option { + for snapshot in snapshots { + if snapshot.get("source").and_then(Value::as_str) != Some("core") { + continue; + } + for key in ["primary", "secondary"] { + let Some(bucket) = snapshot.get(key).filter(|value| !value.is_null()) else { + continue; + }; + if bucket + .get("window_minutes") + .and_then(Value::as_u64) + .is_some_and(|minutes| window_minutes_match(minutes, MONTH_WINDOW_MINUTES)) + { + return Some(json!({ + "key": "core-monthly", + "source": "rate_limit", + "used_percent": bucket.get("used_percent").cloned().unwrap_or(Value::Null), + "remaining_percent": bucket.get("remaining_percent").cloned().unwrap_or(Value::Null), + "reset_at": bucket.get("reset_at").cloned().unwrap_or(Value::Null), + "window_minutes": bucket.get("window_minutes").cloned().unwrap_or(Value::Null), + "limit_reached": bucket.get("limit_reached").cloned().unwrap_or(Value::Bool(false)), + "used_credits": Value::Null, + "limit_credits": Value::Null, + })); + } + } + } + None +} + +fn remaining_percent(used_percent: f64) -> i64 { + (100.0 - used_percent.clamp(0.0, 100.0)).round() as i64 +} + +fn rate_limit_blocked(rate_limit: &Value) -> bool { + rate_limit + .get("limit_reached") + .and_then(Value::as_bool) + .unwrap_or(false) + || rate_limit + .get("allowed") + .and_then(Value::as_bool) + .is_some_and(|allowed| !allowed) +} + +fn window_limit_reached(window: &Value) -> bool { + window + .get("limit_reached") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn spend_control_limit_reached(quota: &Value) -> bool { + quota + .pointer("/spend_control/reached") + .and_then(Value::as_bool) + .unwrap_or(false) + || quota + .pointer("/spend_control/individual_limit/used_percent") + .and_then(number_value) + .is_some_and(|used_percent| used_percent >= 100.0) +} + +fn spend_control_limit_reached_value(spend_control: &Value, used_percent: f64) -> bool { + spend_control + .get("reached") + .and_then(Value::as_bool) + .unwrap_or(false) + || used_percent >= 100.0 +} + +fn monthly_limit_reached(quota: &Value) -> bool { + quota + .pointer("/monthly_limit/limit_reached") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn core_snapshot_blocked(quota: &Value) -> bool { + quota + .get("snapshots") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|snapshot| { + snapshot.get("source").and_then(Value::as_str) == Some("core") + && snapshot + .get("blocked") + .and_then(Value::as_bool) + .unwrap_or(false) + }) +} + +fn blocking_core_bucket(quota: &Value) -> Option<&Value> { + quota + .get("snapshots") + .and_then(Value::as_array)? + .iter() + .filter(|snapshot| snapshot.get("source").and_then(Value::as_str) == Some("core")) + .flat_map(|snapshot| { + ["primary", "secondary"] + .into_iter() + .filter_map(|key| snapshot.get(key)) + }) + .find(|bucket| { + !bucket.is_null() + && bucket + .get("limit_reached") + .and_then(Value::as_bool) + .unwrap_or(false) + }) +} + +fn bucket_reset_at(bucket: &Value) -> Option { + bucket.get("reset_at").and_then(positive_i64) +} + +fn bucket_window_minutes(bucket: &Value) -> Option { + bucket + .get("window_minutes") + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +fn credits_exhausted(quota: &Value) -> bool { + quota + .pointer("/credits/unlimited") + .and_then(Value::as_bool) + .is_some_and(|unlimited| !unlimited) + && quota + .pointer("/credits/has_credits") + .and_then(Value::as_bool) + .is_some_and(|has_credits| !has_credits) +} + +fn number_value(value: &Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) + .filter(|value| value.is_finite()) +} + +fn positive_i64(value: &Value) -> Option { + value + .as_i64() + .filter(|value| *value > 0) + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .filter(|value| *value > 0) +} + +fn trimmed_str(value: &Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn window_minutes_match(actual: u64, expected: u64) -> bool { + actual > 0 && actual.abs_diff(expected) <= expected / 20 } mod runtime; diff --git a/src/upstream/protocol/events.rs b/src/upstream/protocol/events.rs index ec4a96fb6..8b4a01788 100644 --- a/src/upstream/protocol/events.rs +++ b/src/upstream/protocol/events.rs @@ -331,18 +331,65 @@ pub fn rate_limit_quota( plan_type: Option<&str>, existing_quota: Option<&Value>, ) -> Value { - let mut quota = json!({ - "plan_type": plan_type.unwrap_or("unknown"), - "rate_limit": quota_window(rate_limits.primary, true), - "secondary_rate_limit": rate_limits.secondary.map(|window| quota_window(Some(window), false)).unwrap_or(Value::Null), - "code_review_rate_limit": rate_limits.code_review.map(code_review_quota_window).unwrap_or(Value::Null), - }); - - if let Some(credits) = existing_quota.and_then(|quota| quota.get("credits")) { - quota["credits"] = credits.clone(); + let mut snapshots = Vec::new(); + if let Some(snapshot) = quota_snapshot_from_windows( + "core", + None, + None, + rate_limits.primary, + rate_limits.secondary, + None, + None, + ) { + snapshots.push(snapshot); } - quota + let has_code_review_update = rate_limits.code_review.is_some(); + if let Some(details) = rate_limits.code_review { + if let Some(snapshot) = quota_snapshot_from_windows( + "code_review", + Some("code_review"), + None, + details.primary, + details.secondary, + details.allowed, + details.limit_reached, + ) { + snapshots.push(snapshot); + } + } + + if let Some(existing_quota) = existing_quota { + if let Some(existing_snapshots) = existing_quota.get("snapshots").and_then(Value::as_array) + { + for snapshot in existing_snapshots { + let source = snapshot.get("source").and_then(Value::as_str); + if source == Some("additional") + || (!has_code_review_update && source == Some("code_review")) + { + snapshots.push(snapshot.clone()); + } + } + } + } + + let monthly_limit = monthly_limit_from_snapshots(&snapshots) + .or_else(|| existing_quota.and_then(|quota| quota.get("monthly_limit").cloned())) + .unwrap_or(Value::Null); + let credits = existing_quota + .and_then(|quota| quota.get("credits").cloned()) + .unwrap_or(Value::Null); + let spend_control = existing_quota + .and_then(|quota| quota.get("spend_control").cloned()) + .unwrap_or(Value::Null); + + json!({ + "plan_type": plan_type.unwrap_or("unknown"), + "snapshots": snapshots, + "monthly_limit": monthly_limit, + "credits": credits, + "spend_control": spend_control, + }) } /// 将限流状态转换回 HTTP 头键值对。 @@ -559,37 +606,78 @@ fn parse_window_from_object(value: &Value) -> Option { }) } -fn quota_window(window: Option, include_allowed: bool) -> Value { +fn quota_snapshot_from_windows( + source: &str, + limit_name: Option<&str>, + metered_feature: Option<&str>, + primary: Option, + secondary: Option, + allowed: Option, + limit_reached: Option, +) -> Option { + if primary.is_none() && secondary.is_none() { + return None; + } + let blocked = limit_reached.unwrap_or_else(|| { + allowed.is_some_and(|allowed| !allowed) + || primary.is_some_and(|window| window.used_percent >= 100.0) + || secondary.is_some_and(|window| window.used_percent >= 100.0) + }); + Some(json!({ + "source": source, + "limit_name": limit_name, + "metered_feature": metered_feature, + "allowed": allowed, + "limit_reached": limit_reached, + "blocked": blocked, + "primary": quota_window(primary), + "secondary": quota_window(secondary), + })) +} + +fn quota_window(window: Option) -> Value { let Some(window) = window else { return Value::Null; }; let limit_reached = window.used_percent >= 100.0; - let mut value = json!({ + json!({ "used_percent": window.used_percent, "remaining_percent": remaining_percent(window.used_percent), "reset_at": window.reset_at, - "limit_window_seconds": window.limit_window_seconds(), + "window_minutes": window.window_minutes, "limit_reached": limit_reached, - }); - if include_allowed { - value["allowed"] = Value::Bool(!limit_reached); - } - value + }) } -fn code_review_quota_window(details: RateLimitDetails) -> Value { - let used_percent = details.primary.map(|window| window.used_percent); - let limit_reached = details - .limit_reached - .unwrap_or_else(|| used_percent.is_some_and(|used| used >= 100.0)); - json!({ - "allowed": details.allowed.unwrap_or(true), - "limit_reached": limit_reached, - "used_percent": used_percent, - "remaining_percent": used_percent.map(remaining_percent), - "reset_at": details.primary.and_then(|window| window.reset_at), - "limit_window_seconds": details.primary.and_then(RateLimitWindow::limit_window_seconds), - }) +fn monthly_limit_from_snapshots(snapshots: &[Value]) -> Option { + for snapshot in snapshots { + if snapshot.get("source").and_then(Value::as_str) != Some("core") { + continue; + } + for key in ["primary", "secondary"] { + let Some(bucket) = snapshot.get(key).filter(|value| !value.is_null()) else { + continue; + }; + if bucket + .get("window_minutes") + .and_then(Value::as_u64) + .is_some_and(|minutes| minutes.abs_diff(43_200) <= 2_160) + { + return Some(json!({ + "key": "core-monthly", + "source": "rate_limit", + "used_percent": bucket.get("used_percent").cloned().unwrap_or(Value::Null), + "remaining_percent": bucket.get("remaining_percent").cloned().unwrap_or(Value::Null), + "reset_at": bucket.get("reset_at").cloned().unwrap_or(Value::Null), + "window_minutes": bucket.get("window_minutes").cloned().unwrap_or(Value::Null), + "limit_reached": bucket.get("limit_reached").cloned().unwrap_or(Value::Bool(false)), + "used_credits": Value::Null, + "limit_credits": Value::Null, + })); + } + } + } + None } fn remaining_percent(used_percent: f64) -> i64 { diff --git a/tests/admin/accounts/import_export.rs b/tests/admin/accounts/import_export.rs index 048c5b2d5..da7e79f3a 100644 --- a/tests/admin/accounts/import_export.rs +++ b/tests/admin/accounts/import_export.rs @@ -364,7 +364,7 @@ async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_q assert_eq!(stored.account_id.as_deref(), Some("usage-account")); assert_eq!(stored.user_id.as_deref(), Some("supplement-user")); assert_eq!(quota["plan_type"], "free"); - assert_eq!(quota["rate_limit"]["limit_reached"], false); + assert_eq!(quota["snapshots"][0]["blocked"], false); let second_response = app .oneshot( @@ -433,24 +433,55 @@ async fn admin_accounts_import_should_update_existing_sub2api_account_and_clear_ sqlx::query("update accounts set quota_limit_reached = 1, quota_cooldown_until = ?, quota_verify_required = 1 where id = ?") .bind("2026-06-25T00:00:00Z").bind("acct_import_update").execute(&pool).await.unwrap(); - let response = app.clone().oneshot( - Request::builder().method("POST").uri("/api/admin/accounts/import") - .header("content-type", "application/json") - .header("cookie", "cpr_admin_session=session_1") - .header("x-request-id", "req_accounts_import_update") - .body(Body::from(json!({ - "accounts": [{ - "id": "acct_import_update", "email": "new@example.com", - "accountId": "chatgpt-import-update", "userId": "user-import-update", - "label": "new-label", "planType": "plus", - "token": new_access_token, "refreshToken": "new-refresh", - "status": "active", - "cachedQuota": { "rate_limit": { "allowed": true, "limit_reached": false, "used_percent": 5 } }, - "quotaFetchedAt": "2026-06-19T14:00:00Z", "quotaVerifyRequired": false, - "proxyApiKey": "exported-key-prefix", "usage": {"requestCount": 9} - }] - }).to_string())).unwrap(), - ).await.unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_accounts_import_update") + .body(Body::from( + json!({ + "accounts": [{ + "id": "acct_import_update", "email": "new@example.com", + "accountId": "chatgpt-import-update", "userId": "user-import-update", + "label": "new-label", "planType": "plus", + "token": new_access_token, "refreshToken": "new-refresh", + "status": "active", + "cachedQuota": { + "plan_type": "plus", + "snapshots": [{ + "source": "core", + "limit_name": null, + "metered_feature": null, + "allowed": true, + "limit_reached": false, + "blocked": false, + "primary": { + "used_percent": 5, + "remaining_percent": 95, + "reset_at": null, + "window_minutes": 300, + "limit_reached": false + }, + "secondary": null + }], + "monthly_limit": null, + "credits": null, + "spend_control": null + }, + "quotaFetchedAt": "2026-06-19T14:00:00Z", "quotaVerifyRequired": false, + "proxyApiKey": "exported-key-prefix", "usage": {"requestCount": 9} + }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); let status = response.status(); let body = response_json(response).await; let stored = SqliteAccountStore::new(pool.clone(), secret_box) diff --git a/tests/admin/accounts/list.rs b/tests/admin/accounts/list.rs index fb82a95af..a948dbcb7 100644 --- a/tests/admin/accounts/list.rs +++ b/tests/admin/accounts/list.rs @@ -77,10 +77,45 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() { sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ? where id = ?") .bind( json!({ - "rate_limit": { - "used_percent": 87.8, - "reset_at": 1782737460, - "limit_window_seconds": 604800 + "plan_type": "free", + "snapshots": [{ + "source": "core", + "limit_name": null, + "metered_feature": null, + "allowed": true, + "limit_reached": false, + "blocked": false, + "primary": { + "used_percent": 87.8, + "remaining_percent": 12, + "reset_at": 1782737460, + "window_minutes": 10080, + "limit_reached": false + }, + "secondary": { + "used_percent": 12.4, + "remaining_percent": 88, + "reset_at": 1782140000, + "window_minutes": 300, + "limit_reached": false + } + }], + "monthly_limit": { + "key": "spend-control-monthly", + "source": "spend_control", + "used_percent": 32, + "remaining_percent": 68, + "reset_at": 1784268840, + "window_minutes": 43200, + "limit_reached": false + }, + "spend_control": { + "reached": false, + "individual_limit": { + "used_percent": 32, + "remaining_percent": 68, + "reset_at": 1784268840 + } } }) .to_string(), @@ -145,8 +180,13 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() { let body = response_json(response).await; let item = &body["data"]["items"][0]; assert_eq!(item["usage"]["requestCount"], 41); - assert_eq!(item["quota"]["usedPercentDisplay"], "87.8%"); - assert!(item["quota"]["windowUsedDisplay"] + assert_eq!(item["quota"]["windows"][0]["labelDisplay"], "月限额"); + assert_eq!(item["quota"]["windows"][0]["group"], "monthly"); + assert_eq!(item["quota"]["windows"][1]["labelDisplay"], "5小时限额"); + assert_eq!(item["quota"]["windows"][1]["group"], "shortTerm"); + assert_eq!(item["quota"]["windows"][2]["labelDisplay"], "周限额"); + assert_eq!(item["quota"]["windows"][2]["group"], "shortTerm"); + assert!(item["quota"]["windows"][2]["windowUsedDisplay"] .as_str() .is_some_and(|value| value.contains(" / 7.0d"))); assert_eq!(item["usage"]["createdTokens"], 100_000); diff --git a/tests/admin/accounts/quota.rs b/tests/admin/accounts/quota.rs index e35e8848a..03127f6f1 100644 --- a/tests/admin/accounts/quota.rs +++ b/tests/admin/accounts/quota.rs @@ -232,10 +232,45 @@ async fn admin_account_quota_warnings_should_return_threshold_matches_from_cache }, ) .await; - sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?") - .bind(json!({"rate_limit": {"used_percent": 85, "reset_at": 1770000100}, "secondary_rate_limit": {"used_percent": 91, "reset_at": 1770000200}}).to_string()) - .bind("2026-06-13T00:00:00Z").bind("2026-06-13T00:00:00Z").bind("acct_warn") - .execute(&pool).await.unwrap(); + sqlx::query( + "update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?", + ) + .bind( + json!({ + "snapshots": [{ + "source": "core", + "limit_name": null, + "metered_feature": null, + "allowed": true, + "limit_reached": false, + "blocked": false, + "primary": { + "used_percent": 85, + "remaining_percent": 15, + "reset_at": 1770000100, + "window_minutes": 300, + "limit_reached": false + }, + "secondary": { + "used_percent": 91, + "remaining_percent": 9, + "reset_at": 1770000200, + "window_minutes": 10080, + "limit_reached": false + } + }], + "monthly_limit": null, + "credits": null, + "spend_control": null + }) + .to_string(), + ) + .bind("2026-06-13T00:00:00Z") + .bind("2026-06-13T00:00:00Z") + .bind("acct_warn") + .execute(&pool) + .await + .unwrap(); let config = test_config(url); let hasher = ApiKeyHasher::new([90u8; 32]); let stores = BackgroundTaskStores { diff --git a/tests/proxy/dispatch/chat_upstream/usage_logging.rs b/tests/proxy/dispatch/chat_upstream/usage_logging.rs index 8f2112533..31b22ca81 100644 --- a/tests/proxy/dispatch/chat_upstream/usage_logging.rs +++ b/tests/proxy/dispatch/chat_upstream/usage_logging.rs @@ -271,8 +271,9 @@ async fn responses_should_passively_cache_rate_limit_headers() { .await .unwrap(); let quota: Value = serde_json::from_str(&stored.0).unwrap(); - assert_eq!(quota["rate_limit"]["limit_reached"], true); - assert_eq!(quota["rate_limit"]["reset_at"], reset_at); + assert_eq!(quota["snapshots"][0]["source"], "core"); + assert_eq!(quota["snapshots"][0]["primary"]["limit_reached"], true); + assert_eq!(quota["snapshots"][0]["primary"]["reset_at"], reset_at); assert_eq!(quota["credits"]["balance"], 12); assert_eq!(stored.1, 1); assert!(stored.2.is_some()); diff --git a/tests/upstream/accounts/quota_refresh.rs b/tests/upstream/accounts/quota_refresh.rs index 89c7ec6d3..98ac3ec32 100644 --- a/tests/upstream/accounts/quota_refresh.rs +++ b/tests/upstream/accounts/quota_refresh.rs @@ -4,7 +4,7 @@ use chrono::{Duration, Utc}; use codex_proxy_rs::infra::crypto::SecretBox; use codex_proxy_rs::infra::database::connect_sqlite; use codex_proxy_rs::upstream::accounts::model::AccountStatus; -use codex_proxy_rs::upstream::accounts::quota::RuntimeQuotaRefreshService; +use codex_proxy_rs::upstream::accounts::quota::{quota_from_usage, RuntimeQuotaRefreshService}; use codex_proxy_rs::upstream::accounts::store::{NewAccount, SqliteAccountStore}; use codex_proxy_rs::upstream::transport::CodexBackendClient; use secrecy::SecretString; @@ -22,6 +22,121 @@ fn quota_refresh_service_should_expose_default_request_spacing() { ); } +#[test] +fn quota_from_usage_should_preserve_spend_control_individual_limit() { + let usage = json!({ + "plan_type": "plus", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 18, + "reset_at": 1_800_000_000, + "limit_window_seconds": 18_000 + } + }, + "spend_control": { + "reached": false, + "individual_limit": { + "used_percent": 52, + "remaining_percent": 48, + "reset_at": 1_802_592_000 + } + } + }); + + let quota = quota_from_usage(&usage); + + assert_eq!( + quota + .pointer("/monthly_limit/source") + .and_then(Value::as_str), + Some("spend_control") + ); + assert_eq!( + quota + .pointer("/monthly_limit/used_percent") + .and_then(Value::as_f64), + Some(52.0) + ); +} + +#[test] +fn quota_from_usage_should_preserve_additional_limit_snapshot_by_limit_name() { + let usage = json!({ + "plan_type": "plus", + "additional_rate_limits": [{ + "limit_name": "gpt-5.3-codex-spark", + "rate_limit": { + "allowed": false, + "limit_reached": true, + "primary_window": { + "used_percent": 100, + "reset_at": 1_800_000_000, + "limit_window_seconds": 18_000 + }, + "secondary_window": { + "used_percent": 45, + "reset_at": 1_800_604_800, + "limit_window_seconds": 604_800 + } + } + }] + }); + + let quota = quota_from_usage(&usage); + + assert_eq!( + quota.pointer("/snapshots/0/source"), + Some(&Value::String("additional".to_string())) + ); + assert_eq!( + quota.pointer("/snapshots/0/limit_name"), + Some(&Value::String("gpt-5.3-codex-spark".to_string())) + ); + assert_eq!( + quota.pointer("/snapshots/0/blocked"), + Some(&Value::Bool(true)) + ); + assert_eq!( + quota + .pointer("/snapshots/0/secondary/window_minutes") + .and_then(Value::as_u64), + Some(10_080) + ); +} + +#[test] +fn quota_from_usage_should_preserve_additional_limit_name_and_metered_feature() { + let usage = json!({ + "plan_type": "plus", + "additional_rate_limits": [{ + "metered_feature": "review", + "limit_name": "Code review", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 35, + "reset_at": 1_800_000_000, + "limit_window_seconds": 604_800 + } + } + }] + }); + + let quota = quota_from_usage(&usage); + + assert_eq!( + quota.pointer("/snapshots/0/limit_name"), + Some(&Value::String("Code review".to_string())) + ); + assert_eq!( + quota.pointer("/snapshots/0/metered_feature"), + Some(&Value::String("review".to_string())) + ); +} + #[tokio::test] async fn quota_refresh_service_should_fetch_usage_for_quota_locked_accounts_and_store_quota() { let server = MockServer::start().await; @@ -96,13 +211,13 @@ async fn quota_refresh_service_should_fetch_usage_for_quota_locked_accounts_and_ ( summary.refreshed, quota - .pointer("/rate_limit/limit_reached") + .pointer("/snapshots/0/blocked") .and_then(Value::as_bool), quota - .pointer("/rate_limit/remaining_percent") + .pointer("/snapshots/0/primary/remaining_percent") .and_then(Value::as_i64), quota - .pointer("/secondary_rate_limit/limit_reached") + .pointer("/snapshots/0/secondary/limit_reached") .and_then(Value::as_bool), ), (1, Some(false), Some(28), Some(false)) diff --git a/tests/upstream/protocol/usage_rate_limits.rs b/tests/upstream/protocol/usage_rate_limits.rs index 0f8a07a9b..d0543503b 100644 --- a/tests/upstream/protocol/usage_rate_limits.rs +++ b/tests/upstream/protocol/usage_rate_limits.rs @@ -322,7 +322,7 @@ fn parse_rate_limits_event_should_extract_internal_websocket_rate_limits() { } #[test] -fn rate_limit_quota_should_preserve_existing_credits_when_passive_data_lacks_credits() { +fn rate_limit_quota_should_preserve_existing_monthly_and_credits_when_passive_data_lacks_them() { let headers = vec![ ("x-codex-primary-used-percent".to_string(), "25".to_string()), ( @@ -335,6 +335,15 @@ fn rate_limit_quota_should_preserve_existing_credits_when_passive_data_lacks_cre ), ]; let existing = json!({ + "monthly_limit": { + "key": "spend-control-monthly", + "source": "spend_control", + "used_percent": 52, + "remaining_percent": 48, + "reset_at": 1896048000, + "window_minutes": 43200, + "limit_reached": false + }, "credits": { "has_credits": true, "unlimited": false, @@ -346,7 +355,8 @@ fn rate_limit_quota_should_preserve_existing_credits_when_passive_data_lacks_cre let quota = rate_limit_quota(&parsed, Some("plus"), Some(&existing)); assert_eq!(quota["plan_type"], "plus"); - assert_eq!(quota["rate_limit"]["remaining_percent"], 75); + assert_eq!(quota["snapshots"][0]["primary"]["remaining_percent"], 75); + assert_eq!(quota["monthly_limit"]["used_percent"], 52); assert_eq!(quota["credits"]["balance"], 12); } diff --git a/web/src/api/modules/accounts.ts b/web/src/api/modules/accounts.ts index 39391eac4..2bd3abbb7 100644 --- a/web/src/api/modules/accounts.ts +++ b/web/src/api/modules/accounts.ts @@ -20,10 +20,18 @@ export interface Account { } export interface AccountQuota { + refreshedAtDisplay: string + windows: AccountQuotaWindow[] +} + +export interface AccountQuotaWindow { + key: string + group: 'monthly' | 'shortTerm' | 'other' + windowSeconds?: number | null + labelDisplay: string usedPercent?: number | null usedPercentDisplay: string resetAtDisplay: string - refreshedAtDisplay: string windowUsedDisplay: string } diff --git a/web/src/views/accounts/AccountsView.vue b/web/src/views/accounts/AccountsView.vue index 0919dfec2..b8ef666c5 100644 --- a/web/src/views/accounts/AccountsView.vue +++ b/web/src/views/accounts/AccountsView.vue @@ -12,7 +12,7 @@ import BaseModal from '@/components/base/BaseModal.vue' import BaseTable from '@/components/base/BaseTable.vue' import { withMinimumDuration } from '@/utils/async' -import type { Account } from '@/api' +import type { Account, AccountQuotaWindow } from '@/api' import { batchDeleteAccounts, createAccount, @@ -46,7 +46,22 @@ const createForm = ref({ }) const accountColumns = [ - { key: 'selection', label: '', width: '56px', align: 'left' as const }, + { + key: 'expander', + label: '', + width: '40px', + align: 'center' as const, + headerClass: '!px-2', + cellClass: '!px-2', + }, + { + key: 'selection', + label: '', + width: '40px', + align: 'center' as const, + headerClass: '!px-2', + cellClass: '!px-2', + }, { key: 'identity', label: '邮箱', flex: 2.6, minWidth: '260px' }, { key: 'status', label: '状态', flex: 0.8 }, { key: 'planType', label: '套餐', flex: 0.8 }, @@ -260,8 +275,16 @@ function handlePageSizeChange(nextPageSize: number) { void loadAccounts() } -function quotaBarWidth(account: Account) { - return `${Math.max(0, Math.min(account.quota.usedPercent ?? 0, 100))}%` +function quotaWindows(account: Account) { + return account.quota.windows +} + +function quotaWindowsByGroup(account: Account, group: AccountQuotaWindow['group']) { + return quotaWindows(account).filter((window) => window.group === group) +} + +function quotaWindowBarWidth(window: AccountQuotaWindow) { + return `${Math.max(0, Math.min(window.usedPercent ?? 0, 100))}%` } onMounted(() => { @@ -355,6 +378,20 @@ onBeforeUnmount(() => { @page-change="handlePageChange" @page-size-change="handlePageSizeChange" > + +