fix: align official quota windows

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-26 00:27:21 +08:00
co-authored by Codex
parent 9fbd6069bd
commit 672f6798a7
15 changed files with 1541 additions and 282 deletions
+364
View File
@@ -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/<appVersion> (<platform>; <arch>)`。
可确认请求头:
- `Authorization: Bearer <token>`
- `ChatGPT-Account-Id: <jwt auth.chatgpt_account_id>`
- `originator: <desktopOriginator>`
- `User-Agent: Codex Desktop/<version> (<platform>; <arch>)`
同一 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/禁用账号。
+286 -17
View File
@@ -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<AdminAccountQuotaWindowData>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AdminAccountQuotaWindowData {
key: String,
group: String,
window_seconds: Option<u64>,
label_display: String,
used_percent: Option<f64>,
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<DateTime<Utc>>) -> AdminAccountQuotaData {
let quota = serde_json::from_str::<Value>(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::<Utc>::from_timestamp(value, 0));
let window_seconds = quota
.pointer("/rate_limit/limit_window_seconds")
.and_then(Value::as_u64);
let windows = quota_windows(&quota);
AdminAccountQuotaData {
refreshed_at_display: china_relative_time(fetched_at, Utc::now()),
windows,
}
}
fn quota_windows(quota: &Value) -> Vec<AdminAccountQuotaWindowData> {
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<AdminAccountQuotaWindowData>,
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::<Utc>::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<DateTime<Utc>>) -> 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<AdminAccountQuotaWindowData>,
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<AdminAccountQuotaWindowData>,
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::<Utc>::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<String> {
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<u64>,
) -> 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<u64>) -> 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<u64>) -> &'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<u64>) -> 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<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().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 {
+48 -11
View File
@@ -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(&quota) {
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<f64> {
value
.get("used_percent")
.and_then(|value| {
value
.as_f64()
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))
})
.filter(|value| value.is_finite())
}
+20 -2
View File
@@ -925,9 +925,27 @@ async fn account_quota_used_percent_by_id(
fn quota_used_percent(quota_json: &str) -> Option<f64> {
let quota = serde_json::from_str::<Value>(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<f64> {
+283 -127
View File
@@ -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<DateTime<Utc>> {
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::<Utc>::from_timestamp(reset_at, 0)
}
/// 从配额快照读取主 rate-limit 窗口大小。
/// 从配额快照读取窗口大小。
pub fn quota_snapshot_limit_window_seconds(quota: &Value) -> Option<u64> {
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<Value> {
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::<f64>().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<Value> {
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<Value> {
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<i64> {
bucket.get("reset_at").and_then(positive_i64)
}
fn bucket_window_minutes(bucket: &Value) -> Option<u64> {
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<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))
.filter(|value| value.is_finite())
}
fn positive_i64(value: &Value) -> Option<i64> {
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;
+119 -31
View File
@@ -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<RateLimitWindow> {
})
}
fn quota_window(window: Option<RateLimitWindow>, include_allowed: bool) -> Value {
fn quota_snapshot_from_windows(
source: &str,
limit_name: Option<&str>,
metered_feature: Option<&str>,
primary: Option<RateLimitWindow>,
secondary: Option<RateLimitWindow>,
allowed: Option<bool>,
limit_reached: Option<bool>,
) -> Option<Value> {
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<RateLimitWindow>) -> 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<Value> {
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 {
+50 -19
View File
@@ -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)
+46 -6
View File
@@ -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);
+39 -4
View File
@@ -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 {
@@ -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());
+119 -4
View File
@@ -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))
+12 -2
View File
@@ -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);
}
+9 -1
View File
@@ -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
}
+119 -36
View File
@@ -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"
>
<template #expander="{ row }">
<button
type="button"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-(--cp-text-secondary) transition hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary)"
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
@click.stop="toggleExpanded(row.id)"
>
<ChevronDown
class="size-3.5 transition-transform"
:class="expandedAccountIds.has(row.id) ? '' : '-rotate-90'"
/>
</button>
</template>
<template #header-selection>
<BaseCheckbox
:model-value="allSelected"
@@ -365,24 +402,11 @@ onBeforeUnmount(() => {
</template>
<template #selection="{ row }">
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-(--cp-text-secondary) transition hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary)"
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
@click.stop="toggleExpanded(row.id)"
>
<ChevronDown
class="size-3.5 transition-transform"
:class="expandedAccountIds.has(row.id) ? 'rotate-180' : '-rotate-90'"
/>
</button>
<BaseCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
@update:model-value="toggleSelection(row.id)"
/>
</div>
<BaseCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
@update:model-value="toggleSelection(row.id)"
/>
</template>
<template #identity="{ row }">
@@ -481,21 +505,80 @@ onBeforeUnmount(() => {
</BaseIconButton>
</div>
<div class="flex items-center justify-between text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">周限额</span>
<span class="text-(--cp-text-primary)">{{ row.quota.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div class="grid gap-3">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaBarWidth(row) }"
/>
</div>
<div
class="mt-3 flex justify-between text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ row.quota.resetAtDisplay }}</span>
<span>窗口已用: {{ row.quota.windowUsedDisplay }}</span>
v-for="window in quotaWindowsByGroup(row, 'monthly')"
:key="window.key"
class="rounded-lg bg-(--cp-bg-secondary) py-2"
>
<div class="flex items-center justify-between gap-3 text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">{{ window.labelDisplay }}</span>
<span class="text-(--cp-text-primary)">{{ window.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaWindowBarWidth(window) }"
/>
</div>
<div
class="mt-3 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
</div>
<div
v-if="quotaWindowsByGroup(row, 'shortTerm').length > 0"
class="grid gap-2 sm:grid-cols-2"
>
<div
v-for="window in quotaWindowsByGroup(row, 'shortTerm')"
:key="window.key"
class="rounded-lg bg-(--cp-bg-secondary) py-2"
>
<div class="flex items-center justify-between gap-3 text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">{{ window.labelDisplay }}</span>
<span class="text-(--cp-text-primary)">{{ window.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaWindowBarWidth(window) }"
/>
</div>
<div
class="mt-3 flex flex-col gap-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
</div>
</div>
<div
v-for="window in quotaWindowsByGroup(row, 'other')"
:key="window.key"
class="rounded-lg bg-(--cp-bg-secondary) py-2"
>
<div class="flex items-center justify-between gap-3 text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">{{ window.labelDisplay }}</span>
<span class="text-(--cp-text-primary)">{{ window.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaWindowBarWidth(window) }"
/>
</div>
<div
class="mt-3 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
</div>
</div>
</section>
@@ -143,10 +143,10 @@ const quotaToneClasses: Record<SemanticTone, string> = {
<template>
<BaseCard as="article" :padded="false" class="w-full xl:h-112.5">
<div
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] xl:gap-7 xl:pb-0"
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
>
<section
class="w-full pb-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:h-100.5 xl:pb-0 xl:shadow-none"
class="min-w-0 w-full pb-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:h-100.5 xl:pb-0 xl:shadow-none"
>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
@@ -198,7 +198,7 @@ const quotaToneClasses: Record<SemanticTone, string> = {
</section>
<section
class="w-full py-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:py-0 xl:shadow-none"
class="min-w-0 w-full py-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:py-0 xl:shadow-none"
>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">活跃账号用量</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
@@ -217,7 +217,7 @@ const quotaToneClasses: Record<SemanticTone, string> = {
<article
v-for="(account, index) in accounts"
:key="account.name"
class="grid w-full grid-cols-[34px_minmax(0,1fr)] gap-x-3 rounded-[14px] px-3.5 py-3.5 hover:bg-(--cp-bg-subtle) xl:h-19.5 xl:grid-cols-[34px_minmax(150px,1fr)_76px_84px_84px] xl:items-center xl:gap-x-3 xl:py-0"
class="grid w-full grid-cols-[34px_minmax(0,1fr)] gap-x-3 rounded-[14px] px-3.5 py-3.5 hover:bg-(--cp-bg-subtle) xl:h-19.5 xl:grid-cols-[34px_minmax(0,1.28fr)_minmax(132px,0.86fr)] xl:items-center xl:gap-x-3 xl:py-0"
:class="['bg-(--cp-bg-subtle)', 'bg-white'][index % 2]"
>
<span
@@ -226,39 +226,43 @@ const quotaToneClasses: Record<SemanticTone, string> = {
>
{{ account.name[0] }}
</span>
<span class="grid min-w-0 gap-1.5 xl:gap-4.25">
<span class="flex min-w-0 items-start">
<strong class="text-sm leading-[1.15] font-[650] text-(--cp-text-primary)">{{
account.name
}}</strong>
<span class="flex min-w-0 max-w-full flex-col gap-1.5 xl:gap-4.25">
<span class="flex min-w-0 max-w-full items-start gap-1.5">
<strong
class="min-w-0 truncate text-sm leading-[1.15] font-[650] text-(--cp-text-primary)"
:title="account.name"
>{{ account.name }}</strong
>
<small
class="ml-1.5 mt-0.75 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)"
class="mt-0.75 shrink-0 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)"
>{{ account.plan }}</small
>
</span>
<span
class="min-w-0 truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
class="block min-w-0 max-w-full truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
:title="account.email"
>{{ account.email }}</span
>
</span>
<span
class="col-span-2 mt-3 grid grid-cols-3 gap-3 pl-11 xl:col-span-3 xl:mt-0 xl:grid-cols-[76px_84px_84px] xl:pl-0"
class="col-span-2 mt-3 grid grid-cols-3 gap-3 pl-11 xl:col-span-1 xl:mt-0 xl:grid-cols-[minmax(0,56px)_minmax(72px,1fr)] xl:gap-6 xl:pl-0"
>
<span class="grid min-w-0 gap-1.5">
<span class="grid min-w-0 gap-1.5 xl:gap-1">
<span
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
>请求</span
>最近</span
>
<strong
class="w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.requests }}</strong
class="hidden shrink-0 font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary) xl:block"
>{{ account.tokens }}</strong
>
<span
class="w-full truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary) xl:w-20 xl:whitespace-nowrap"
>{{ account.lastUsed }}</span
class="min-w-0 truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary) xl:whitespace-nowrap"
>
{{ account.lastUsed }}
</span>
</span>
<span class="grid min-w-0 gap-1.5">
<span class="grid min-w-0 gap-1.5 xl:hidden">
<span
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
>Token</span
@@ -292,7 +296,7 @@ const quotaToneClasses: Record<SemanticTone, string> = {
</div>
</section>
<section class="w-full pt-6 xl:h-100.5 xl:pt-0">
<section class="min-w-0 w-full pt-6 xl:h-100.5 xl:pt-0">
<header class="flex h-12.5 items-start justify-between">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号状态</h2>