icebear0828 commited on
Commit
e336506
·
2 Parent(s): 21cd05d247b874

Merge branch 'feat/window-counters'

Browse files
CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@
8
 
9
  ### Added
10
 
 
 
11
  - Dashboard 账号列表新增手动刷新按钮:点击重新拉取额度数据,刷新中按钮旋转并禁用;独立 `refreshing` 状态确保刷新时列表不清空;标题行右侧显示"更新于 HH:MM:SS"时间戳(桌面端可见)
12
  - 空响应计数器:每个账号追踪 `empty_response_count`,通过 `GET /auth/accounts` 可查看,窗口重置时自动归零
13
  - 空响应日志增强:日志中显示账号邮箱(`Account xxxx (email) | Empty response`),便于定位问题账号
@@ -31,6 +33,7 @@
31
 
32
  ### Fixed
33
 
 
34
  - 空响应重试循环中账号双重释放:外层 catch 使用原始 `entryId` 而非当前活跃账号,导致换号重试失败时 double-release(`proxy-handler.ts`)
35
  - `apply-update.ts` 模型比较不再误报删除:静态提取只含 2 个硬编码模型,与 YAML 的 24 个比较会产生 22 个假删除,现在只报新增
36
  - `update-checker.ts` 子进程超时保护:`fork()` 添加 5 分钟 kill timer,防止挂起导致 `_updateInProgress` 永久锁定
 
8
 
9
  ### Added
10
 
11
+ - 每窗口使用量计数器:Dashboard 主显示当前窗口内的请求数和 Token 用量,累计总量降为次要灰色小字;窗口过期时自动归零(时间驱动,零 API 开销),后端同步作为双保险校正
12
+ - 窗口时长显示:从后端同步 `limit_window_seconds`,AccountCard header 显示窗口时长 badge(如 `3h`),重置时间行追加窗口时长文字
13
  - Dashboard 账号列表新增手动刷新按钮:点击重新拉取额度数据,刷新中按钮旋转并禁用;独立 `refreshing` 状态确保刷新时列表不清空;标题行右侧显示"更新于 HH:MM:SS"时间戳(桌面端可见)
14
  - 空响应计数器:每个账号追踪 `empty_response_count`,通过 `GET /auth/accounts` 可查看,窗口重置时自动归零
15
  - 空响应日志增强:日志中显示账号邮箱(`Account xxxx (email) | Empty response`),便于定位问题账号
 
33
 
34
  ### Fixed
35
 
36
+ - 额度窗口刷新后 Dashboard 仍显示累计 Token:本地计数器从未按窗口重置,现在 `refreshStatus()` 每次 acquire/getAccounts 时检查 `window_reset_at`,过期自动归零窗口计数器
37
  - 空响应重试循环中账号双重释放:外层 catch 使用原始 `entryId` 而非当前活跃账号,导致换号重试失败时 double-release(`proxy-handler.ts`)
38
  - `apply-update.ts` 模型比较不再误报删除:静态提取只含 2 个硬编码模型,与 YAML 的 24 个比较会产生 22 个假删除,现在只报新增
39
  - `update-checker.ts` 子进程超时保护:`fork()` 添加 5 分钟 kill timer,防止挂起导致 `_updateInProgress` 永久锁定
src/auth/account-pool.ts CHANGED
@@ -128,6 +128,12 @@ export class AccountPool {
128
  entry.usage.input_tokens += usage.input_tokens ?? 0;
129
  entry.usage.output_tokens += usage.output_tokens ?? 0;
130
  }
 
 
 
 
 
 
131
  this.schedulePersist();
132
  }
133
 
@@ -156,6 +162,7 @@ export class AccountPool {
156
  if (options?.countRequest) {
157
  entry.usage.request_count++;
158
  entry.usage.last_used = new Date().toISOString();
 
159
  }
160
 
161
  this.schedulePersist();
@@ -206,6 +213,11 @@ export class AccountPool {
206
  empty_response_count: 0,
207
  last_used: null,
208
  rate_limit_until: null,
 
 
 
 
 
209
  },
210
  addedAt: new Date().toISOString(),
211
  };
@@ -269,6 +281,11 @@ export class AccountPool {
269
  last_used: null,
270
  rate_limit_until: null,
271
  window_reset_at: entry.usage.window_reset_at ?? null,
 
 
 
 
 
272
  };
273
  this.schedulePersist();
274
  return true;
@@ -279,16 +296,27 @@ export class AccountPool {
279
  * If so, auto-reset local usage counters to stay in sync.
280
  * Called after fetching quota from OpenAI API.
281
  */
282
- syncRateLimitWindow(entryId: string, newResetAt: number | null): void {
 
 
 
 
283
  if (newResetAt == null) return;
284
  const entry = this.accounts.get(entryId);
285
  if (!entry) return;
286
 
287
  const oldResetAt = entry.usage.window_reset_at;
288
  if (oldResetAt != null && oldResetAt !== newResetAt) {
289
- console.log(`[AccountPool] Rate limit window rolled for ${entryId} (${entry.email ?? "?"}), updating window timestamp`);
 
 
 
 
290
  }
291
  entry.usage.window_reset_at = newResetAt;
 
 
 
292
  this.schedulePersist();
293
  }
294
 
@@ -399,6 +427,27 @@ export class AccountPool {
399
  if (entry.status === "active" && isTokenExpired(entry.token)) {
400
  entry.status = "expired";
401
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  }
403
 
404
  private toInfo(entry: AccountEntry): AccountInfo {
@@ -477,6 +526,15 @@ export class AccountPool {
477
  entry.usage.empty_response_count = 0;
478
  needsPersist = true;
479
  }
 
 
 
 
 
 
 
 
 
480
  this.accounts.set(entry.id, entry);
481
  }
482
  }
@@ -519,6 +577,11 @@ export class AccountPool {
519
  empty_response_count: 0,
520
  last_used: null,
521
  rate_limit_until: null,
 
 
 
 
 
522
  },
523
  addedAt: new Date().toISOString(),
524
  };
 
128
  entry.usage.input_tokens += usage.input_tokens ?? 0;
129
  entry.usage.output_tokens += usage.output_tokens ?? 0;
130
  }
131
+ // Increment window counters
132
+ entry.usage.window_request_count = (entry.usage.window_request_count ?? 0) + 1;
133
+ if (usage) {
134
+ entry.usage.window_input_tokens = (entry.usage.window_input_tokens ?? 0) + (usage.input_tokens ?? 0);
135
+ entry.usage.window_output_tokens = (entry.usage.window_output_tokens ?? 0) + (usage.output_tokens ?? 0);
136
+ }
137
  this.schedulePersist();
138
  }
139
 
 
162
  if (options?.countRequest) {
163
  entry.usage.request_count++;
164
  entry.usage.last_used = new Date().toISOString();
165
+ entry.usage.window_request_count = (entry.usage.window_request_count ?? 0) + 1;
166
  }
167
 
168
  this.schedulePersist();
 
213
  empty_response_count: 0,
214
  last_used: null,
215
  rate_limit_until: null,
216
+ window_request_count: 0,
217
+ window_input_tokens: 0,
218
+ window_output_tokens: 0,
219
+ window_counters_reset_at: null,
220
+ limit_window_seconds: null,
221
  },
222
  addedAt: new Date().toISOString(),
223
  };
 
281
  last_used: null,
282
  rate_limit_until: null,
283
  window_reset_at: entry.usage.window_reset_at ?? null,
284
+ window_request_count: 0,
285
+ window_input_tokens: 0,
286
+ window_output_tokens: 0,
287
+ window_counters_reset_at: new Date().toISOString(),
288
+ limit_window_seconds: entry.usage.limit_window_seconds ?? null,
289
  };
290
  this.schedulePersist();
291
  return true;
 
296
  * If so, auto-reset local usage counters to stay in sync.
297
  * Called after fetching quota from OpenAI API.
298
  */
299
+ syncRateLimitWindow(
300
+ entryId: string,
301
+ newResetAt: number | null,
302
+ limitWindowSeconds: number | null,
303
+ ): void {
304
  if (newResetAt == null) return;
305
  const entry = this.accounts.get(entryId);
306
  if (!entry) return;
307
 
308
  const oldResetAt = entry.usage.window_reset_at;
309
  if (oldResetAt != null && oldResetAt !== newResetAt) {
310
+ console.log(`[AccountPool] Rate limit window rolled for ${entryId} (${entry.email ?? "?"}), resetting window counters`);
311
+ entry.usage.window_request_count = 0;
312
+ entry.usage.window_input_tokens = 0;
313
+ entry.usage.window_output_tokens = 0;
314
+ entry.usage.window_counters_reset_at = new Date().toISOString();
315
  }
316
  entry.usage.window_reset_at = newResetAt;
317
+ if (limitWindowSeconds != null) {
318
+ entry.usage.limit_window_seconds = limitWindowSeconds;
319
+ }
320
  this.schedulePersist();
321
  }
322
 
 
427
  if (entry.status === "active" && isTokenExpired(entry.token)) {
428
  entry.status = "expired";
429
  }
430
+
431
+ // Auto-reset window counters when window has expired
432
+ const windowResetAt = entry.usage.window_reset_at;
433
+ const nowSec = now.getTime() / 1000;
434
+ if (windowResetAt != null && nowSec >= windowResetAt) {
435
+ console.log(`[AccountPool] Window expired for ${entry.id} (${entry.email ?? "?"}), resetting window counters`);
436
+ entry.usage.window_request_count = 0;
437
+ entry.usage.window_input_tokens = 0;
438
+ entry.usage.window_output_tokens = 0;
439
+ entry.usage.window_counters_reset_at = now.toISOString();
440
+ // Jump to the correct current window (handles multi-window catch-up in one step)
441
+ const windowSec = entry.usage.limit_window_seconds;
442
+ if (windowSec && windowSec > 0) {
443
+ let nextReset = windowResetAt + windowSec;
444
+ while (nextReset <= nowSec) nextReset += windowSec;
445
+ entry.usage.window_reset_at = nextReset;
446
+ } else {
447
+ entry.usage.window_reset_at = null; // Wait for backend sync to correct
448
+ }
449
+ this.schedulePersist();
450
+ }
451
  }
452
 
453
  private toInfo(entry: AccountEntry): AccountInfo {
 
526
  entry.usage.empty_response_count = 0;
527
  needsPersist = true;
528
  }
529
+ // Backfill window counter fields for old entries
530
+ if (entry.usage.window_request_count == null) {
531
+ entry.usage.window_request_count = 0;
532
+ entry.usage.window_input_tokens = 0;
533
+ entry.usage.window_output_tokens = 0;
534
+ entry.usage.window_counters_reset_at = null;
535
+ entry.usage.limit_window_seconds = null;
536
+ needsPersist = true;
537
+ }
538
  this.accounts.set(entry.id, entry);
539
  }
540
  }
 
577
  empty_response_count: 0,
578
  last_used: null,
579
  rate_limit_until: null,
580
+ window_request_count: 0,
581
+ window_input_tokens: 0,
582
+ window_output_tokens: 0,
583
+ window_counters_reset_at: null,
584
+ limit_window_seconds: null,
585
  },
586
  addedAt: new Date().toISOString(),
587
  };
src/auth/types.ts CHANGED
@@ -18,6 +18,16 @@ export interface AccountUsage {
18
  rate_limit_until: string | null;
19
  /** Tracks the current rate limit window end (Unix seconds). When window rolls over, counters reset. */
20
  window_reset_at?: number | null;
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
  export interface AccountEntry {
@@ -54,6 +64,7 @@ export interface CodexQuota {
54
  limit_reached: boolean;
55
  used_percent: number | null;
56
  reset_at: number | null;
 
57
  };
58
  code_review_rate_limit: {
59
  allowed: boolean;
 
18
  rate_limit_until: string | null;
19
  /** Tracks the current rate limit window end (Unix seconds). When window rolls over, counters reset. */
20
  window_reset_at?: number | null;
21
+ /** Per-window request count (resets when window expires). */
22
+ window_request_count?: number;
23
+ /** Per-window input tokens (resets when window expires). */
24
+ window_input_tokens?: number;
25
+ /** Per-window output tokens (resets when window expires). */
26
+ window_output_tokens?: number;
27
+ /** ISO timestamp of when window counters were last reset. */
28
+ window_counters_reset_at?: string | null;
29
+ /** Window duration in seconds, synced from backend, used for local window estimation. */
30
+ limit_window_seconds?: number | null;
31
  }
32
 
33
  export interface AccountEntry {
 
64
  limit_reached: boolean;
65
  used_percent: number | null;
66
  reset_at: number | null;
67
+ limit_window_seconds: number | null;
68
  };
69
  code_review_rate_limit: {
70
  allowed: boolean;
src/routes/accounts.ts CHANGED
@@ -32,6 +32,7 @@ function toQuota(usage: CodexUsageResponse): CodexQuota {
32
  limit_reached: usage.rate_limit.limit_reached,
33
  used_percent: usage.rate_limit.primary_window?.used_percent ?? null,
34
  reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
 
35
  },
36
  code_review_rate_limit: usage.code_review_rate_limit
37
  ? {
@@ -88,7 +89,8 @@ export function createAccountRoutes(
88
  const usage = await api.getUsage();
89
  // Sync rate limit window — auto-reset local counters on window rollover
90
  const resetAt = usage.rate_limit.primary_window?.reset_at ?? null;
91
- pool.syncRateLimitWindow(acct.id, resetAt);
 
92
  // Re-read usage after potential reset
93
  const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
94
  return { ...freshAcct, quota: toQuota(usage) };
 
32
  limit_reached: usage.rate_limit.limit_reached,
33
  used_percent: usage.rate_limit.primary_window?.used_percent ?? null,
34
  reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
35
+ limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null,
36
  },
37
  code_review_rate_limit: usage.code_review_rate_limit
38
  ? {
 
89
  const usage = await api.getUsage();
90
  // Sync rate limit window — auto-reset local counters on window rollover
91
  const resetAt = usage.rate_limit.primary_window?.reset_at ?? null;
92
+ const windowSec = usage.rate_limit.primary_window?.limit_window_seconds ?? null;
93
+ pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
94
  // Re-read usage after potential reset
95
  const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
96
  return { ...freshAcct, quota: toQuota(usage) };
web/src/components/AccountCard.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useCallback } from "preact/hooks";
2
  import { useT, useI18n } from "../i18n/context";
3
  import type { TranslationKey } from "../i18n/translations";
4
- import { formatNumber, formatResetTime } from "../utils/format";
5
  import type { Account } from "../hooks/use-accounts";
6
 
7
  const avatarColors = [
@@ -50,7 +50,11 @@ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
50
  const usage = account.usage || {};
51
  const requests = usage.request_count ?? 0;
52
  const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
 
 
53
  const plan = account.planType || t("freeTier");
 
 
54
 
55
  const [statusCls, statusKey] = statusStyles[account.status] || statusStyles.disabled;
56
 
@@ -86,7 +90,14 @@ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
86
  </div>
87
  <div>
88
  <h3 class="text-[0.82rem] font-semibold leading-tight">{email}</h3>
89
- <p class="text-xs text-slate-500 dark:text-text-dim">{plan}</p>
 
 
 
 
 
 
 
90
  </div>
91
  </div>
92
  <div class="flex items-center gap-2">
@@ -108,12 +119,16 @@ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
108
  {/* Stats */}
109
  <div class="space-y-2">
110
  <div class="flex justify-between text-[0.78rem]">
111
- <span class="text-slate-500 dark:text-text-dim">{t("totalRequests")}</span>
112
- <span class="font-medium">{formatNumber(requests)}</span>
113
  </div>
114
  <div class="flex justify-between text-[0.78rem]">
115
- <span class="text-slate-500 dark:text-text-dim">{t("tokensUsed")}</span>
116
- <span class="font-medium">{formatNumber(tokens)}</span>
 
 
 
 
117
  </div>
118
  </div>
119
 
@@ -142,6 +157,11 @@ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
142
  {resetAt && (
143
  <p class="text-xs text-slate-400 dark:text-text-dim mt-1">
144
  {t("resetsAt")} {resetAt}
 
 
 
 
 
145
  </p>
146
  )}
147
  </div>
 
1
  import { useCallback } from "preact/hooks";
2
  import { useT, useI18n } from "../i18n/context";
3
  import type { TranslationKey } from "../i18n/translations";
4
+ import { formatNumber, formatResetTime, formatWindowDuration } from "../utils/format";
5
  import type { Account } from "../hooks/use-accounts";
6
 
7
  const avatarColors = [
 
50
  const usage = account.usage || {};
51
  const requests = usage.request_count ?? 0;
52
  const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
53
+ const winRequests = usage.window_request_count ?? 0;
54
+ const winTokens = (usage.window_input_tokens ?? 0) + (usage.window_output_tokens ?? 0);
55
  const plan = account.planType || t("freeTier");
56
+ const windowSec = account.quota?.rate_limit?.limit_window_seconds;
57
+ const windowDur = windowSec ? formatWindowDuration(windowSec, lang === "zh") : null;
58
 
59
  const [statusCls, statusKey] = statusStyles[account.status] || statusStyles.disabled;
60
 
 
90
  </div>
91
  <div>
92
  <h3 class="text-[0.82rem] font-semibold leading-tight">{email}</h3>
93
+ <p class="text-xs text-slate-500 dark:text-text-dim">
94
+ {plan}
95
+ {windowDur && (
96
+ <span class="ml-1.5 px-1.5 py-0.5 rounded bg-slate-100 dark:bg-border-dark text-slate-500 dark:text-text-dim text-[0.65rem] font-medium">
97
+ {windowDur}
98
+ </span>
99
+ )}
100
+ </p>
101
  </div>
102
  </div>
103
  <div class="flex items-center gap-2">
 
119
  {/* Stats */}
120
  <div class="space-y-2">
121
  <div class="flex justify-between text-[0.78rem]">
122
+ <span class="text-slate-500 dark:text-text-dim">{t("windowRequests")}</span>
123
+ <span class="font-medium">{formatNumber(winRequests)}</span>
124
  </div>
125
  <div class="flex justify-between text-[0.78rem]">
126
+ <span class="text-slate-500 dark:text-text-dim">{t("windowTokens")}</span>
127
+ <span class="font-medium">{formatNumber(winTokens)}</span>
128
+ </div>
129
+ <div class="flex justify-between text-[0.68rem]">
130
+ <span class="text-slate-400 dark:text-text-dim/70">{t("totalAll")}</span>
131
+ <span class="text-slate-400 dark:text-text-dim/70">{formatNumber(requests)} req · {formatNumber(tokens)} tok</span>
132
  </div>
133
  </div>
134
 
 
157
  {resetAt && (
158
  <p class="text-xs text-slate-400 dark:text-text-dim mt-1">
159
  {t("resetsAt")} {resetAt}
160
+ {windowDur && (
161
+ <span class="ml-1 text-slate-400 dark:text-text-dim/70">
162
+ ({t("windowLabel")} {windowDur})
163
+ </span>
164
+ )}
165
  </p>
166
  )}
167
  </div>
web/src/hooks/use-accounts.ts CHANGED
@@ -5,6 +5,7 @@ export interface AccountQuota {
5
  used_percent?: number | null;
6
  limit_reached?: boolean;
7
  reset_at?: number | null;
 
8
  };
9
  }
10
 
@@ -17,6 +18,9 @@ export interface Account {
17
  request_count?: number;
18
  input_tokens?: number;
19
  output_tokens?: number;
 
 
 
20
  };
21
  quota?: AccountQuota;
22
  }
 
5
  used_percent?: number | null;
6
  limit_reached?: boolean;
7
  reset_at?: number | null;
8
+ limit_window_seconds?: number | null;
9
  };
10
  }
11
 
 
18
  request_count?: number;
19
  input_tokens?: number;
20
  output_tokens?: number;
21
+ window_request_count?: number;
22
+ window_input_tokens?: number;
23
+ window_output_tokens?: number;
24
  };
25
  quota?: AccountQuota;
26
  }
web/src/i18n/translations.ts CHANGED
@@ -19,6 +19,10 @@ export const translations = {
19
  freeTier: "Free Tier",
20
  totalRequests: "Total Requests",
21
  tokensUsed: "Tokens Used",
 
 
 
 
22
  rateLimit: "Rate Limit",
23
  limitReached: "Limit Reached",
24
  used: "Used",
@@ -78,6 +82,10 @@ export const translations = {
78
  freeTier: "\u514d\u8d39\u7248",
79
  totalRequests: "\u603b\u8bf7\u6c42\u6570",
80
  tokensUsed: "Token \u7528\u91cf",
 
 
 
 
81
  rateLimit: "\u901f\u7387\u9650\u5236",
82
  limitReached: "\u5df2\u8fbe\u4e0a\u9650",
83
  used: "\u5df2\u4f7f\u7528",
 
19
  freeTier: "Free Tier",
20
  totalRequests: "Total Requests",
21
  tokensUsed: "Tokens Used",
22
+ windowRequests: "Requests (Window)",
23
+ windowTokens: "Tokens (Window)",
24
+ totalAll: "Total",
25
+ windowLabel: "window",
26
  rateLimit: "Rate Limit",
27
  limitReached: "Limit Reached",
28
  used: "Used",
 
82
  freeTier: "\u514d\u8d39\u7248",
83
  totalRequests: "\u603b\u8bf7\u6c42\u6570",
84
  tokensUsed: "Token \u7528\u91cf",
85
+ windowRequests: "\u8bf7\u6c42\u6570\uff08\u7a97\u53e3\uff09",
86
+ windowTokens: "Token\uff08\u7a97\u53e3\uff09",
87
+ totalAll: "\u7d2f\u8ba1",
88
+ windowLabel: "\u7a97\u53e3",
89
  rateLimit: "\u901f\u7387\u9650\u5236",
90
  limitReached: "\u5df2\u8fbe\u4e0a\u9650",
91
  used: "\u5df2\u4f7f\u7528",
web/src/utils/format.ts CHANGED
@@ -4,6 +4,19 @@ export function formatNumber(n: number): string {
4
  return String(n);
5
  }
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  export function formatResetTime(unixSec: number, isZh: boolean): string {
8
  const d = new Date(unixSec * 1000);
9
  const now = new Date();
 
4
  return String(n);
5
  }
6
 
7
+ export function formatWindowDuration(seconds: number, isZh: boolean): string {
8
+ if (seconds >= 86400) {
9
+ const days = Math.floor(seconds / 86400);
10
+ return isZh ? `${days}天` : `${days}d`;
11
+ }
12
+ if (seconds >= 3600) {
13
+ const hours = Math.floor(seconds / 3600);
14
+ return isZh ? `${hours}小时` : `${hours}h`;
15
+ }
16
+ const minutes = Math.floor(seconds / 60);
17
+ return isZh ? `${minutes}分钟` : `${minutes}m`;
18
+ }
19
+
20
  export function formatResetTime(unixSec: number, isZh: boolean): string {
21
  const d = new Date(unixSec * 1000);
22
  const now = new Date();
web/tailwind.config.ts CHANGED
@@ -13,7 +13,7 @@ export default {
13
  "card-dark": "#161b22",
14
  "border-dark": "#30363d",
15
  "text-main": "#e6edf3",
16
- "text-dim": "#8b949e",
17
  },
18
  fontFamily: {
19
  display: [
 
13
  "card-dark": "#161b22",
14
  "border-dark": "#30363d",
15
  "text-main": "#e6edf3",
16
+ "text-dim": "rgb(139 148 158 / <alpha-value>)",
17
  },
18
  fontFamily: {
19
  display: [