icebear icebear0828 commited on
Commit
4f2665c
·
unverified ·
1 Parent(s): 5e966fa

feat: auto-refresh quota with tiered warnings (#92) (#93)

Browse files

Background quota refresher fetches official usage every 5min (configurable),
caches on AccountEntry for instant dashboard reads, and marks exhausted
accounts as rate_limited. Tiered warning thresholds (default 80%/90%) trigger
visual banners in the dashboard. Frontend auto-polls every 30s.

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@
8
 
9
  ### Added
10
 
 
11
  - Docker 镜像自动发布:push master 自动构建多架构(amd64/arm64)镜像到 GHCR(`ghcr.io/icebear0828/codex-proxy`),docker-compose.yml 切换为预构建镜像,支持 Watchtower 自动更新
12
  - 双窗口配额显示:Dashboard 账号卡片同时展示主窗口(小时限制)和次窗口(周限制)的用量百分比、进度条和重置时间,后端 `secondary_window` 不再被忽略
13
  - 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
 
8
 
9
  ### Added
10
 
11
+ - 额度自动刷新 + 分层预警:后台每 5 分钟(可配置)定时拉取所有账号的官方额度,缓存到 AccountEntry 供 Dashboard 即时读取;额度达到阈值(默认 80%/90%,可自定义)时显示 warning/critical 横幅;额度耗尽的账号自动标记为 rate_limited 跳过分配,到期自动恢复 (#92)
12
  - Docker 镜像自动发布:push master 自动构建多架构(amd64/arm64)镜像到 GHCR(`ghcr.io/icebear0828/codex-proxy`),docker-compose.yml 切换为预构建镜像,支持 Watchtower 自动更新
13
  - 双窗口配额显示:Dashboard 账号卡片同时展示主窗口(小时限制)和次窗口(周限制)的用量百分比、进度条和重置时间,后端 `secondary_window` 不再被忽略
14
  - 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
config/default.yaml CHANGED
@@ -35,3 +35,9 @@ tls:
35
  impersonate_profile: chrome144
36
  proxy_url: null
37
  force_http11: true
 
 
 
 
 
 
 
35
  impersonate_profile: chrome144
36
  proxy_url: null
37
  force_http11: true
38
+ quota:
39
+ refresh_interval_minutes: 5
40
+ warning_thresholds:
41
+ primary: [80, 90]
42
+ secondary: [80, 90]
43
+ skip_exhausted: true
package.json CHANGED
@@ -2,6 +2,9 @@
2
  "name": "codex-proxy",
3
  "version": "1.0.55",
4
  "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions",
 
 
 
5
  "type": "module",
6
  "scripts": {
7
  "test": "vitest run",
 
2
  "name": "codex-proxy",
3
  "version": "1.0.55",
4
  "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions",
5
+ "contributors": [
6
+ { "name": "Huangcoolbo", "url": "https://github.com/Huangcoolbo" }
7
+ ],
8
  "type": "module",
9
  "scripts": {
10
  "test": "vitest run",
shared/hooks/use-accounts.ts CHANGED
@@ -10,10 +10,11 @@ export function useAccounts() {
10
  const [addInfo, setAddInfo] = useState("");
11
  const [addError, setAddError] = useState("");
12
 
13
- const loadAccounts = useCallback(async () => {
14
  setRefreshing(true);
15
  try {
16
- const resp = await fetch("/auth/accounts?quota=true");
 
17
  const data = await resp.json();
18
  setList(data.accounts || []);
19
  setLastUpdated(new Date());
@@ -29,6 +30,12 @@ export function useAccounts() {
29
  loadAccounts();
30
  }, [loadAccounts]);
31
 
 
 
 
 
 
 
32
  // Listen for OAuth callback success
33
  useEffect(() => {
34
  const handler = async (event: MessageEvent) => {
@@ -218,7 +225,7 @@ export function useAccounts() {
218
  addVisible,
219
  addInfo,
220
  addError,
221
- refresh: loadAccounts,
222
  patchLocal,
223
  startAdd,
224
  submitRelay,
 
10
  const [addInfo, setAddInfo] = useState("");
11
  const [addError, setAddError] = useState("");
12
 
13
+ const loadAccounts = useCallback(async (fresh = false) => {
14
  setRefreshing(true);
15
  try {
16
+ const url = fresh ? "/auth/accounts?quota=fresh" : "/auth/accounts?quota=true";
17
+ const resp = await fetch(url);
18
  const data = await resp.json();
19
  setList(data.accounts || []);
20
  setLastUpdated(new Date());
 
30
  loadAccounts();
31
  }, [loadAccounts]);
32
 
33
+ // Auto-poll cached quota every 30s
34
+ useEffect(() => {
35
+ const timer = setInterval(() => loadAccounts(), 30_000);
36
+ return () => clearInterval(timer);
37
+ }, [loadAccounts]);
38
+
39
  // Listen for OAuth callback success
40
  useEffect(() => {
41
  const handler = async (event: MessageEvent) => {
 
225
  addVisible,
226
  addInfo,
227
  addError,
228
+ refresh: useCallback(() => loadAccounts(true), [loadAccounts]),
229
  patchLocal,
230
  startAdd,
231
  submitRelay,
shared/i18n/translations.ts CHANGED
@@ -179,6 +179,8 @@ export const translations = {
179
  statusPass: "Pass",
180
  statusFail: "Fail",
181
  statusSkip: "Skip",
 
 
182
  },
183
  zh: {
184
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -363,6 +365,8 @@ export const translations = {
363
  statusPass: "\u901a\u8fc7",
364
  statusFail: "\u5931\u8d25",
365
  statusSkip: "\u8df3\u8fc7",
 
 
366
  },
367
  } as const;
368
 
 
179
  statusPass: "Pass",
180
  statusFail: "Fail",
181
  statusSkip: "Skip",
182
+ quotaCriticalWarning: "{count} account(s) approaching quota limit",
183
+ quotaWarning: "{count} account(s) quota usage elevated",
184
  },
185
  zh: {
186
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
365
  statusPass: "\u901a\u8fc7",
366
  statusFail: "\u5931\u8d25",
367
  statusSkip: "\u8df3\u8fc7",
368
+ quotaCriticalWarning: "{count} \u4e2a\u8d26\u53f7\u989d\u5ea6\u5373\u5c06\u8017\u5c3d",
369
+ quotaWarning: "{count} \u4e2a\u8d26\u53f7\u989d\u5ea6\u7528\u91cf\u8f83\u9ad8",
370
  },
371
  } as const;
372
 
shared/types.ts CHANGED
@@ -1,10 +1,22 @@
 
 
 
 
 
 
 
1
  export interface AccountQuota {
2
- rate_limit?: {
3
- used_percent?: number | null;
4
- limit_reached?: boolean;
5
- reset_at?: number | null;
6
- limit_window_seconds?: number | null;
7
- };
 
 
 
 
 
8
  }
9
 
10
  export interface Account {
@@ -21,6 +33,7 @@ export interface Account {
21
  window_output_tokens?: number;
22
  };
23
  quota?: AccountQuota;
 
24
  proxyId?: string;
25
  proxyName?: string;
26
  }
 
1
+ export interface AccountQuotaWindow {
2
+ used_percent?: number | null;
3
+ limit_reached?: boolean;
4
+ reset_at?: number | null;
5
+ limit_window_seconds?: number | null;
6
+ }
7
+
8
  export interface AccountQuota {
9
+ rate_limit?: AccountQuotaWindow;
10
+ secondary_rate_limit?: AccountQuotaWindow | null;
11
+ }
12
+
13
+ export interface QuotaWarning {
14
+ accountId: string;
15
+ email: string | null;
16
+ window: "primary" | "secondary";
17
+ level: "warning" | "critical";
18
+ usedPercent: number;
19
+ resetAt: number | null;
20
  }
21
 
22
  export interface Account {
 
33
  window_output_tokens?: number;
34
  };
35
  quota?: AccountQuota;
36
+ quotaFetchedAt?: string | null;
37
  proxyId?: string;
38
  proxyName?: string;
39
  }
src/auth/__tests__/account-pool-quota.test.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for AccountPool quota-related methods:
3
+ * - updateCachedQuota()
4
+ * - markQuotaExhausted()
5
+ * - toInfo() populating cached quota
6
+ * - loadPersisted() backfill
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+
11
+ vi.mock("fs", () => ({
12
+ readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
13
+ writeFileSync: vi.fn(),
14
+ renameSync: vi.fn(),
15
+ existsSync: vi.fn(() => false),
16
+ mkdirSync: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("../../paths.js", () => ({
20
+ getDataDir: vi.fn(() => "/tmp/test-data"),
21
+ getConfigDir: vi.fn(() => "/tmp/test-config"),
22
+ }));
23
+
24
+ vi.mock("../../config.js", () => ({
25
+ getConfig: vi.fn(() => ({
26
+ auth: {
27
+ jwt_token: null,
28
+ rotation_strategy: "least_used",
29
+ rate_limit_backoff_seconds: 60,
30
+ },
31
+ server: { proxy_api_key: null },
32
+ quota: {
33
+ refresh_interval_minutes: 5,
34
+ warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
35
+ skip_exhausted: true,
36
+ },
37
+ })),
38
+ }));
39
+
40
+ // Use a counter to generate unique accountIds
41
+ let _idCounter = 0;
42
+ vi.mock("../../auth/jwt-utils.js", () => ({
43
+ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
44
+ extractChatGptAccountId: vi.fn(() => `acct-${++_idCounter}`),
45
+ extractUserProfile: vi.fn(() => ({
46
+ email: `user${_idCounter}@test.com`,
47
+ chatgpt_plan_type: "plus",
48
+ })),
49
+ isTokenExpired: vi.fn(() => false),
50
+ }));
51
+
52
+ vi.mock("../../utils/jitter.js", () => ({
53
+ jitter: vi.fn((val: number) => val),
54
+ }));
55
+
56
+ vi.mock("../../models/model-store.js", () => ({
57
+ getModelPlanTypes: vi.fn(() => []),
58
+ }));
59
+
60
+ import { AccountPool } from "../account-pool.js";
61
+ import type { CodexQuota } from "../types.js";
62
+
63
+ function makeQuota(overrides?: Partial<CodexQuota>): CodexQuota {
64
+ return {
65
+ plan_type: "plus",
66
+ rate_limit: {
67
+ allowed: true,
68
+ limit_reached: false,
69
+ used_percent: 42,
70
+ reset_at: Math.floor(Date.now() / 1000) + 3600,
71
+ limit_window_seconds: 3600,
72
+ },
73
+ secondary_rate_limit: null,
74
+ code_review_rate_limit: null,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ describe("AccountPool quota methods", () => {
80
+ let pool: AccountPool;
81
+
82
+ beforeEach(() => {
83
+ pool = new AccountPool();
84
+ });
85
+
86
+ describe("updateCachedQuota", () => {
87
+ it("stores quota and timestamp on account", () => {
88
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-aaa");
89
+ const quota = makeQuota();
90
+
91
+ pool.updateCachedQuota(id, quota);
92
+
93
+ const entry = pool.getEntry(id);
94
+ expect(entry?.cachedQuota).toEqual(quota);
95
+ expect(entry?.quotaFetchedAt).toBeTruthy();
96
+ });
97
+
98
+ it("no-ops for unknown entry", () => {
99
+ // Should not throw
100
+ pool.updateCachedQuota("nonexistent", makeQuota());
101
+ });
102
+ });
103
+
104
+ describe("markQuotaExhausted", () => {
105
+ it("sets status to rate_limited with reset time", () => {
106
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-bbb");
107
+ const resetAt = Math.floor(Date.now() / 1000) + 7200;
108
+
109
+ pool.markQuotaExhausted(id, resetAt);
110
+
111
+ const entry = pool.getEntry(id);
112
+ expect(entry?.status).toBe("rate_limited");
113
+ expect(entry?.usage.rate_limit_until).toBeTruthy();
114
+ });
115
+
116
+ it("uses fallback when resetAt is null", () => {
117
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ccc");
118
+
119
+ pool.markQuotaExhausted(id, null);
120
+
121
+ const entry = pool.getEntry(id);
122
+ expect(entry?.status).toBe("rate_limited");
123
+ expect(entry?.usage.rate_limit_until).toBeTruthy();
124
+ });
125
+
126
+ it("does not override non-active status", () => {
127
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ddd");
128
+ pool.markStatus(id, "disabled");
129
+
130
+ pool.markQuotaExhausted(id, Math.floor(Date.now() / 1000) + 3600);
131
+
132
+ const entry = pool.getEntry(id);
133
+ expect(entry?.status).toBe("disabled"); // unchanged
134
+ });
135
+ });
136
+
137
+ describe("toInfo with cached quota", () => {
138
+ it("populates quota field from cachedQuota", () => {
139
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-eee");
140
+ const quota = makeQuota({ plan_type: "team" });
141
+
142
+ pool.updateCachedQuota(id, quota);
143
+
144
+ const accounts = pool.getAccounts();
145
+ const acct = accounts.find((a) => a.id === id);
146
+ expect(acct?.quota).toEqual(quota);
147
+ expect(acct?.quotaFetchedAt).toBeTruthy();
148
+ });
149
+
150
+ it("does not include quota when cachedQuota is null", () => {
151
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-fff");
152
+
153
+ const accounts = pool.getAccounts();
154
+ const acct = accounts.find((a) => a.id === id);
155
+ expect(acct?.quota).toBeUndefined();
156
+ });
157
+ });
158
+
159
+ describe("acquire skips exhausted accounts", () => {
160
+ it("skips rate_limited (quota exhausted) account", () => {
161
+ const id1 = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ggg");
162
+ const id2 = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-hhh");
163
+
164
+ // Exhaust first account
165
+ pool.markQuotaExhausted(id1, Math.floor(Date.now() / 1000) + 7200);
166
+
167
+ const acquired = pool.acquire();
168
+ expect(acquired).not.toBeNull();
169
+ expect(acquired!.entryId).toBe(id2);
170
+ pool.release(acquired!.entryId);
171
+ });
172
+
173
+ it("returns null when all accounts exhausted", () => {
174
+ const id1 = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-iii");
175
+ pool.markQuotaExhausted(id1, Math.floor(Date.now() / 1000) + 7200);
176
+
177
+ const acquired = pool.acquire();
178
+ expect(acquired).toBeNull();
179
+ });
180
+ });
181
+ });
src/auth/__tests__/config-quota.test.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for quota config section parsing.
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from "vitest";
6
+
7
+ // Mock dependencies before importing config
8
+ vi.mock("../../models/model-store.js", () => ({
9
+ loadStaticModels: vi.fn(),
10
+ }));
11
+ vi.mock("../../models/model-fetcher.js", () => ({
12
+ triggerImmediateRefresh: vi.fn(),
13
+ }));
14
+
15
+ import { z } from "zod";
16
+
17
+ // Replicate the quota schema to test independently
18
+ const QuotaSchema = z.object({
19
+ refresh_interval_minutes: z.number().min(1).default(5),
20
+ warning_thresholds: z.object({
21
+ primary: z.array(z.number().min(1).max(100)).default([80, 90]),
22
+ secondary: z.array(z.number().min(1).max(100)).default([80, 90]),
23
+ }).default({}),
24
+ skip_exhausted: z.boolean().default(true),
25
+ }).default({});
26
+
27
+ describe("quota config schema", () => {
28
+ it("uses defaults when empty", () => {
29
+ const result = QuotaSchema.parse({});
30
+ expect(result.refresh_interval_minutes).toBe(5);
31
+ expect(result.warning_thresholds.primary).toEqual([80, 90]);
32
+ expect(result.warning_thresholds.secondary).toEqual([80, 90]);
33
+ expect(result.skip_exhausted).toBe(true);
34
+ });
35
+
36
+ it("uses defaults when undefined", () => {
37
+ const result = QuotaSchema.parse(undefined);
38
+ expect(result.refresh_interval_minutes).toBe(5);
39
+ });
40
+
41
+ it("accepts custom thresholds", () => {
42
+ const result = QuotaSchema.parse({
43
+ refresh_interval_minutes: 10,
44
+ warning_thresholds: {
45
+ primary: [70, 85, 95],
46
+ secondary: [60],
47
+ },
48
+ skip_exhausted: false,
49
+ });
50
+ expect(result.refresh_interval_minutes).toBe(10);
51
+ expect(result.warning_thresholds.primary).toEqual([70, 85, 95]);
52
+ expect(result.warning_thresholds.secondary).toEqual([60]);
53
+ expect(result.skip_exhausted).toBe(false);
54
+ });
55
+
56
+ it("rejects refresh_interval_minutes < 1", () => {
57
+ expect(() => QuotaSchema.parse({ refresh_interval_minutes: 0 })).toThrow();
58
+ });
59
+
60
+ it("rejects threshold > 100", () => {
61
+ expect(() => QuotaSchema.parse({
62
+ warning_thresholds: { primary: [101] },
63
+ })).toThrow();
64
+ });
65
+
66
+ it("rejects threshold < 1", () => {
67
+ expect(() => QuotaSchema.parse({
68
+ warning_thresholds: { primary: [0] },
69
+ })).toThrow();
70
+ });
71
+ });
src/auth/__tests__/quota-utils.test.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { toQuota } from "../quota-utils.js";
3
+ import type { CodexUsageResponse } from "../../proxy/codex-api.js";
4
+
5
+ function makeUsageResponse(overrides?: Partial<CodexUsageResponse>): CodexUsageResponse {
6
+ return {
7
+ plan_type: "plus",
8
+ rate_limit: {
9
+ allowed: true,
10
+ limit_reached: false,
11
+ primary_window: {
12
+ used_percent: 42,
13
+ reset_at: 1700000000,
14
+ limit_window_seconds: 3600,
15
+ reset_after_seconds: 1800,
16
+ },
17
+ secondary_window: null,
18
+ },
19
+ code_review_rate_limit: null,
20
+ credits: null,
21
+ promo: null,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("toQuota", () => {
27
+ it("converts primary window correctly", () => {
28
+ const quota = toQuota(makeUsageResponse());
29
+ expect(quota.plan_type).toBe("plus");
30
+ expect(quota.rate_limit.used_percent).toBe(42);
31
+ expect(quota.rate_limit.reset_at).toBe(1700000000);
32
+ expect(quota.rate_limit.limit_window_seconds).toBe(3600);
33
+ expect(quota.rate_limit.limit_reached).toBe(false);
34
+ expect(quota.rate_limit.allowed).toBe(true);
35
+ expect(quota.secondary_rate_limit).toBeNull();
36
+ expect(quota.code_review_rate_limit).toBeNull();
37
+ });
38
+
39
+ it("converts secondary window when present", () => {
40
+ const quota = toQuota(makeUsageResponse({
41
+ rate_limit: {
42
+ allowed: true,
43
+ limit_reached: false,
44
+ primary_window: {
45
+ used_percent: 10,
46
+ reset_at: 1700000000,
47
+ limit_window_seconds: 3600,
48
+ reset_after_seconds: 3000,
49
+ },
50
+ secondary_window: {
51
+ used_percent: 75,
52
+ reset_at: 1700500000,
53
+ limit_window_seconds: 604800,
54
+ reset_after_seconds: 300000,
55
+ },
56
+ },
57
+ }));
58
+
59
+ expect(quota.secondary_rate_limit).not.toBeNull();
60
+ expect(quota.secondary_rate_limit!.used_percent).toBe(75);
61
+ expect(quota.secondary_rate_limit!.reset_at).toBe(1700500000);
62
+ expect(quota.secondary_rate_limit!.limit_window_seconds).toBe(604800);
63
+ });
64
+
65
+ it("converts code review rate limit when present", () => {
66
+ const quota = toQuota(makeUsageResponse({
67
+ code_review_rate_limit: {
68
+ allowed: true,
69
+ limit_reached: true,
70
+ primary_window: {
71
+ used_percent: 100,
72
+ reset_at: 1700001000,
73
+ limit_window_seconds: 3600,
74
+ reset_after_seconds: 0,
75
+ },
76
+ secondary_window: null,
77
+ },
78
+ }));
79
+
80
+ expect(quota.code_review_rate_limit).not.toBeNull();
81
+ expect(quota.code_review_rate_limit!.allowed).toBe(true);
82
+ expect(quota.code_review_rate_limit!.limit_reached).toBe(true);
83
+ expect(quota.code_review_rate_limit!.used_percent).toBe(100);
84
+ });
85
+
86
+ it("handles null primary window gracefully", () => {
87
+ const quota = toQuota(makeUsageResponse({
88
+ rate_limit: {
89
+ allowed: true,
90
+ limit_reached: false,
91
+ primary_window: null,
92
+ secondary_window: null,
93
+ },
94
+ }));
95
+
96
+ expect(quota.rate_limit.used_percent).toBeNull();
97
+ expect(quota.rate_limit.reset_at).toBeNull();
98
+ expect(quota.rate_limit.limit_window_seconds).toBeNull();
99
+ });
100
+ });
src/auth/__tests__/quota-warnings.test.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ evaluateThresholds,
4
+ updateWarnings,
5
+ clearWarnings,
6
+ getActiveWarnings,
7
+ getWarningsLastUpdated,
8
+ } from "../quota-warnings.js";
9
+
10
+ describe("evaluateThresholds", () => {
11
+ it("returns null when usedPercent is null", () => {
12
+ const result = evaluateThresholds("a1", "a@test.com", null, null, "primary", [80, 90]);
13
+ expect(result).toBeNull();
14
+ });
15
+
16
+ it("returns null when below all thresholds", () => {
17
+ const result = evaluateThresholds("a1", "a@test.com", 50, 1700000000, "primary", [80, 90]);
18
+ expect(result).toBeNull();
19
+ });
20
+
21
+ it("returns warning when between thresholds", () => {
22
+ const result = evaluateThresholds("a1", "a@test.com", 85, 1700000000, "primary", [80, 90]);
23
+ expect(result).not.toBeNull();
24
+ expect(result!.level).toBe("warning");
25
+ expect(result!.usedPercent).toBe(85);
26
+ expect(result!.window).toBe("primary");
27
+ });
28
+
29
+ it("returns critical when at or above highest threshold", () => {
30
+ const result = evaluateThresholds("a1", "a@test.com", 95, 1700000000, "primary", [80, 90]);
31
+ expect(result).not.toBeNull();
32
+ expect(result!.level).toBe("critical");
33
+ expect(result!.usedPercent).toBe(95);
34
+ });
35
+
36
+ it("returns critical when exactly at highest threshold", () => {
37
+ const result = evaluateThresholds("a1", null, 90, null, "secondary", [80, 90]);
38
+ expect(result).not.toBeNull();
39
+ expect(result!.level).toBe("critical");
40
+ expect(result!.email).toBeNull();
41
+ });
42
+
43
+ it("handles single threshold (always critical)", () => {
44
+ const result = evaluateThresholds("a1", "a@test.com", 80, null, "primary", [80]);
45
+ expect(result).not.toBeNull();
46
+ expect(result!.level).toBe("critical");
47
+ });
48
+
49
+ it("handles unsorted thresholds", () => {
50
+ const result = evaluateThresholds("a1", "a@test.com", 85, null, "primary", [90, 80]);
51
+ expect(result).not.toBeNull();
52
+ expect(result!.level).toBe("warning"); // 85 >= 80 but < 90
53
+ });
54
+
55
+ it("handles three thresholds", () => {
56
+ // [60, 80, 90]: 75 should be warning (between 60 and 80)
57
+ const result = evaluateThresholds("a1", null, 75, null, "primary", [60, 80, 90]);
58
+ expect(result).not.toBeNull();
59
+ expect(result!.level).toBe("warning");
60
+ });
61
+ });
62
+
63
+ describe("warning store", () => {
64
+ beforeEach(() => {
65
+ // Clear all warnings
66
+ for (const w of getActiveWarnings()) {
67
+ clearWarnings(w.accountId);
68
+ }
69
+ });
70
+
71
+ it("starts empty", () => {
72
+ expect(getActiveWarnings()).toEqual([]);
73
+ });
74
+
75
+ it("stores and retrieves warnings", () => {
76
+ updateWarnings("a1", [
77
+ { accountId: "a1", email: "a@test.com", window: "primary", level: "warning", usedPercent: 85, resetAt: null },
78
+ ]);
79
+ const all = getActiveWarnings();
80
+ expect(all).toHaveLength(1);
81
+ expect(all[0].accountId).toBe("a1");
82
+ expect(getWarningsLastUpdated()).not.toBeNull();
83
+ });
84
+
85
+ it("replaces warnings for same account", () => {
86
+ updateWarnings("a1", [
87
+ { accountId: "a1", email: null, window: "primary", level: "warning", usedPercent: 85, resetAt: null },
88
+ ]);
89
+ updateWarnings("a1", [
90
+ { accountId: "a1", email: null, window: "primary", level: "critical", usedPercent: 95, resetAt: null },
91
+ ]);
92
+ const all = getActiveWarnings();
93
+ expect(all).toHaveLength(1);
94
+ expect(all[0].level).toBe("critical");
95
+ });
96
+
97
+ it("clears warnings for account", () => {
98
+ updateWarnings("a1", [
99
+ { accountId: "a1", email: null, window: "primary", level: "warning", usedPercent: 85, resetAt: null },
100
+ ]);
101
+ updateWarnings("a2", [
102
+ { accountId: "a2", email: null, window: "secondary", level: "critical", usedPercent: 95, resetAt: null },
103
+ ]);
104
+ clearWarnings("a1");
105
+ const all = getActiveWarnings();
106
+ expect(all).toHaveLength(1);
107
+ expect(all[0].accountId).toBe("a2");
108
+ });
109
+
110
+ it("removes warnings when updated with empty array", () => {
111
+ updateWarnings("a1", [
112
+ { accountId: "a1", email: null, window: "primary", level: "warning", usedPercent: 85, resetAt: null },
113
+ ]);
114
+ updateWarnings("a1", []);
115
+ expect(getActiveWarnings()).toHaveLength(0);
116
+ });
117
+ });
src/auth/account-pool.ts CHANGED
@@ -28,6 +28,7 @@ import type {
28
  AccountUsage,
29
  AcquiredAccount,
30
  AccountsFile,
 
31
  } from "./types.js";
32
 
33
  function getAccountsFile(): string {
@@ -304,6 +305,8 @@ export class AccountPool {
304
  limit_window_seconds: null,
305
  },
306
  addedAt: new Date().toISOString(),
 
 
307
  };
308
 
309
  this.accounts.set(id, entry);
@@ -321,6 +324,36 @@ export class AccountPool {
321
  this.schedulePersist();
322
  }
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  removeAccount(id: string): boolean {
325
  this.acquireLocks.delete(id);
326
  const deleted = this.accounts.delete(id);
@@ -545,7 +578,7 @@ export class AccountPool {
545
  private toInfo(entry: AccountEntry): AccountInfo {
546
  const payload = decodeJwtPayload(entry.token);
547
  const exp = payload?.exp;
548
- return {
549
  id: entry.id,
550
  email: entry.email,
551
  accountId: entry.accountId,
@@ -558,6 +591,11 @@ export class AccountPool {
558
  ? new Date(exp * 1000).toISOString()
559
  : null,
560
  };
 
 
 
 
 
561
  }
562
 
563
  // ── Persistence ─────────────────────────────────────────────────
@@ -629,6 +667,12 @@ export class AccountPool {
629
  entry.usage.limit_window_seconds = null;
630
  needsPersist = true;
631
  }
 
 
 
 
 
 
632
  this.accounts.set(entry.id, entry);
633
  }
634
  }
@@ -680,6 +724,8 @@ export class AccountPool {
680
  limit_window_seconds: null,
681
  },
682
  addedAt: new Date().toISOString(),
 
 
683
  };
684
 
685
  this.accounts.set(id, entry);
 
28
  AccountUsage,
29
  AcquiredAccount,
30
  AccountsFile,
31
+ CodexQuota,
32
  } from "./types.js";
33
 
34
  function getAccountsFile(): string {
 
305
  limit_window_seconds: null,
306
  },
307
  addedAt: new Date().toISOString(),
308
+ cachedQuota: null,
309
+ quotaFetchedAt: null,
310
  };
311
 
312
  this.accounts.set(id, entry);
 
324
  this.schedulePersist();
325
  }
326
 
327
+ /**
328
+ * Update cached quota for an account (called by background quota refresher).
329
+ */
330
+ updateCachedQuota(entryId: string, quota: CodexQuota): void {
331
+ const entry = this.accounts.get(entryId);
332
+ if (!entry) return;
333
+ entry.cachedQuota = quota;
334
+ entry.quotaFetchedAt = new Date().toISOString();
335
+ this.schedulePersist();
336
+ }
337
+
338
+ /**
339
+ * Mark an account as quota-exhausted by setting it rate_limited until resetAt.
340
+ * Reuses the rate_limited mechanism so acquire() skips it and refreshStatus() auto-recovers.
341
+ */
342
+ markQuotaExhausted(entryId: string, resetAtUnix: number | null): void {
343
+ const entry = this.accounts.get(entryId);
344
+ if (!entry) return;
345
+ // Only mark if currently active — don't override other states
346
+ if (entry.status !== "active") return;
347
+
348
+ const until = resetAtUnix
349
+ ? new Date(resetAtUnix * 1000).toISOString()
350
+ : new Date(Date.now() + 300_000).toISOString(); // fallback 5 min
351
+ entry.status = "rate_limited";
352
+ entry.usage.rate_limit_until = until;
353
+ this.acquireLocks.delete(entryId);
354
+ this.schedulePersist();
355
+ }
356
+
357
  removeAccount(id: string): boolean {
358
  this.acquireLocks.delete(id);
359
  const deleted = this.accounts.delete(id);
 
578
  private toInfo(entry: AccountEntry): AccountInfo {
579
  const payload = decodeJwtPayload(entry.token);
580
  const exp = payload?.exp;
581
+ const info: AccountInfo = {
582
  id: entry.id,
583
  email: entry.email,
584
  accountId: entry.accountId,
 
591
  ? new Date(exp * 1000).toISOString()
592
  : null,
593
  };
594
+ if (entry.cachedQuota) {
595
+ info.quota = entry.cachedQuota;
596
+ info.quotaFetchedAt = entry.quotaFetchedAt;
597
+ }
598
+ return info;
599
  }
600
 
601
  // ── Persistence ─────────────────────────────────────────────────
 
667
  entry.usage.limit_window_seconds = null;
668
  needsPersist = true;
669
  }
670
+ // Backfill cachedQuota fields for old entries
671
+ if (entry.cachedQuota === undefined) {
672
+ entry.cachedQuota = null;
673
+ entry.quotaFetchedAt = null;
674
+ needsPersist = true;
675
+ }
676
  this.accounts.set(entry.id, entry);
677
  }
678
  }
 
724
  limit_window_seconds: null,
725
  },
726
  addedAt: new Date().toISOString(),
727
+ cachedQuota: null,
728
+ quotaFetchedAt: null,
729
  };
730
 
731
  this.accounts.set(id, entry);
src/auth/quota-utils.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared quota conversion utility.
3
+ * Converts CodexUsageResponse (raw backend) → CodexQuota (normalized).
4
+ */
5
+
6
+ import type { CodexQuota } from "./types.js";
7
+ import type { CodexUsageResponse } from "../proxy/codex-api.js";
8
+
9
+ export function toQuota(usage: CodexUsageResponse): CodexQuota {
10
+ const sw = usage.rate_limit.secondary_window;
11
+ return {
12
+ plan_type: usage.plan_type,
13
+ rate_limit: {
14
+ allowed: usage.rate_limit.allowed,
15
+ limit_reached: usage.rate_limit.limit_reached,
16
+ used_percent: usage.rate_limit.primary_window?.used_percent ?? null,
17
+ reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
18
+ limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null,
19
+ },
20
+ secondary_rate_limit: sw
21
+ ? {
22
+ limit_reached: usage.rate_limit.limit_reached,
23
+ used_percent: sw.used_percent ?? null,
24
+ reset_at: sw.reset_at ?? null,
25
+ limit_window_seconds: sw.limit_window_seconds ?? null,
26
+ }
27
+ : null,
28
+ code_review_rate_limit: usage.code_review_rate_limit
29
+ ? {
30
+ allowed: usage.code_review_rate_limit.allowed,
31
+ limit_reached: usage.code_review_rate_limit.limit_reached,
32
+ used_percent:
33
+ usage.code_review_rate_limit.primary_window?.used_percent ?? null,
34
+ reset_at:
35
+ usage.code_review_rate_limit.primary_window?.reset_at ?? null,
36
+ }
37
+ : null,
38
+ };
39
+ }
src/auth/quota-warnings.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Quota warning state store.
3
+ * Tracks accounts approaching or exceeding quota limits.
4
+ */
5
+
6
+ export interface QuotaWarning {
7
+ accountId: string;
8
+ email: string | null;
9
+ window: "primary" | "secondary";
10
+ level: "warning" | "critical";
11
+ usedPercent: number;
12
+ resetAt: number | null;
13
+ }
14
+
15
+ const _warnings = new Map<string, QuotaWarning[]>();
16
+ let _lastUpdated: string | null = null;
17
+
18
+ /**
19
+ * Evaluate quota thresholds and return warnings for a single account.
20
+ * Thresholds are sorted ascending; highest matched threshold determines level.
21
+ */
22
+ export function evaluateThresholds(
23
+ accountId: string,
24
+ email: string | null,
25
+ usedPercent: number | null,
26
+ resetAt: number | null,
27
+ window: "primary" | "secondary",
28
+ thresholds: number[],
29
+ ): QuotaWarning | null {
30
+ if (usedPercent == null) return null;
31
+ // Sort ascending
32
+ const sorted = [...thresholds].sort((a, b) => a - b);
33
+ // Find highest matched threshold
34
+ let matchedIdx = -1;
35
+ for (let i = sorted.length - 1; i >= 0; i--) {
36
+ if (usedPercent >= sorted[i]) {
37
+ matchedIdx = i;
38
+ break;
39
+ }
40
+ }
41
+ if (matchedIdx < 0) return null;
42
+
43
+ // Highest threshold = critical, others = warning
44
+ const level: "warning" | "critical" =
45
+ matchedIdx === sorted.length - 1 ? "critical" : "warning";
46
+
47
+ return { accountId, email, window, level, usedPercent, resetAt };
48
+ }
49
+
50
+ export function updateWarnings(accountId: string, warnings: QuotaWarning[]): void {
51
+ if (warnings.length === 0) {
52
+ _warnings.delete(accountId);
53
+ } else {
54
+ _warnings.set(accountId, warnings);
55
+ }
56
+ _lastUpdated = new Date().toISOString();
57
+ }
58
+
59
+ export function clearWarnings(accountId: string): void {
60
+ _warnings.delete(accountId);
61
+ }
62
+
63
+ export function getActiveWarnings(): QuotaWarning[] {
64
+ const all: QuotaWarning[] = [];
65
+ for (const list of _warnings.values()) {
66
+ all.push(...list);
67
+ }
68
+ return all;
69
+ }
70
+
71
+ export function getWarningsLastUpdated(): string | null {
72
+ return _lastUpdated;
73
+ }
src/auth/types.ts CHANGED
@@ -41,6 +41,10 @@ export interface AccountEntry {
41
  status: AccountStatus;
42
  usage: AccountUsage;
43
  addedAt: string;
 
 
 
 
44
  }
45
 
46
  /** Public info (no token) */
@@ -54,6 +58,7 @@ export interface AccountInfo {
54
  addedAt: string;
55
  expiresAt: string | null;
56
  quota?: CodexQuota;
 
57
  }
58
 
59
  /** A single rate limit window (primary or secondary). */
 
41
  status: AccountStatus;
42
  usage: AccountUsage;
43
  addedAt: string;
44
+ /** Cached official quota from background refresh. Null until first fetch. */
45
+ cachedQuota: CodexQuota | null;
46
+ /** ISO timestamp of when cachedQuota was last updated. */
47
+ quotaFetchedAt: string | null;
48
  }
49
 
50
  /** Public info (no token) */
 
58
  addedAt: string;
59
  expiresAt: string | null;
60
  quota?: CodexQuota;
61
+ quotaFetchedAt?: string | null;
62
  }
63
 
64
  /** A single rate limit window (primary or secondary). */
src/auth/usage-refresher.ts ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Usage Refresher — background quota refresh for all accounts.
3
+ *
4
+ * - Periodically fetches official quota from Codex backend
5
+ * - Caches quota on AccountEntry for fast dashboard reads
6
+ * - Marks quota-exhausted accounts as rate_limited
7
+ * - Evaluates warning thresholds and updates warning store
8
+ * - Non-fatal: all errors log warnings but never crash the server
9
+ */
10
+
11
+ import { CodexApi } from "../proxy/codex-api.js";
12
+ import { getConfig } from "../config.js";
13
+ import { jitter } from "../utils/jitter.js";
14
+ import { toQuota } from "./quota-utils.js";
15
+ import {
16
+ evaluateThresholds,
17
+ updateWarnings,
18
+ type QuotaWarning,
19
+ } from "./quota-warnings.js";
20
+ import type { AccountPool } from "./account-pool.js";
21
+ import type { CookieJar } from "../proxy/cookie-jar.js";
22
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
23
+
24
+ const INITIAL_DELAY_MS = 3_000; // 3s after startup
25
+
26
+ let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
27
+ let _accountPool: AccountPool | null = null;
28
+ let _cookieJar: CookieJar | null = null;
29
+ let _proxyPool: ProxyPool | null = null;
30
+
31
+ async function fetchQuotaForAllAccounts(
32
+ pool: AccountPool,
33
+ cookieJar: CookieJar,
34
+ proxyPool: ProxyPool | null,
35
+ ): Promise<void> {
36
+ if (!pool.isAuthenticated()) return;
37
+
38
+ const entries = pool.getAllEntries().filter((e) => e.status === "active");
39
+ if (entries.length === 0) return;
40
+
41
+ const config = getConfig();
42
+ const thresholds = config.quota.warning_thresholds;
43
+
44
+ console.log(`[QuotaRefresh] Refreshing quota for ${entries.length} active account(s)`);
45
+
46
+ const results = await Promise.allSettled(
47
+ entries.map(async (entry) => {
48
+ const proxyUrl = proxyPool?.resolveProxyUrl(entry.id);
49
+ const api = new CodexApi(entry.token, entry.accountId, cookieJar, entry.id, proxyUrl);
50
+ const usage = await api.getUsage();
51
+ const quota = toQuota(usage);
52
+
53
+ // Cache quota on the account
54
+ pool.updateCachedQuota(entry.id, quota);
55
+
56
+ // Sync rate limit window
57
+ const resetAt = usage.rate_limit.primary_window?.reset_at ?? null;
58
+ const windowSec = usage.rate_limit.primary_window?.limit_window_seconds ?? null;
59
+ pool.syncRateLimitWindow(entry.id, resetAt, windowSec);
60
+
61
+ // Mark exhausted if limit reached (primary or secondary)
62
+ if (config.quota.skip_exhausted) {
63
+ const primaryExhausted = quota.rate_limit.limit_reached;
64
+ const secondaryExhausted = quota.secondary_rate_limit?.limit_reached ?? false;
65
+ if (primaryExhausted || secondaryExhausted) {
66
+ const exhaustResetAt = primaryExhausted
67
+ ? quota.rate_limit.reset_at
68
+ : quota.secondary_rate_limit?.reset_at ?? null;
69
+ pool.markQuotaExhausted(entry.id, exhaustResetAt);
70
+ console.log(`[QuotaRefresh] Account ${entry.id} (${entry.email ?? "?"}) quota exhausted — marked rate_limited`);
71
+ }
72
+ }
73
+
74
+ // Evaluate warning thresholds
75
+ const warnings: QuotaWarning[] = [];
76
+ const pw = evaluateThresholds(
77
+ entry.id,
78
+ entry.email,
79
+ quota.rate_limit.used_percent,
80
+ quota.rate_limit.reset_at,
81
+ "primary",
82
+ thresholds.primary,
83
+ );
84
+ if (pw) warnings.push(pw);
85
+
86
+ const sw = evaluateThresholds(
87
+ entry.id,
88
+ entry.email,
89
+ quota.secondary_rate_limit?.used_percent ?? null,
90
+ quota.secondary_rate_limit?.reset_at ?? null,
91
+ "secondary",
92
+ thresholds.secondary,
93
+ );
94
+ if (sw) warnings.push(sw);
95
+
96
+ updateWarnings(entry.id, warnings);
97
+ }),
98
+ );
99
+
100
+ let succeeded = 0;
101
+ for (const r of results) {
102
+ if (r.status === "fulfilled") {
103
+ succeeded++;
104
+ } else {
105
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
106
+ console.warn(`[QuotaRefresh] Account quota fetch failed: ${msg}`);
107
+ }
108
+ }
109
+
110
+ console.log(`[QuotaRefresh] Done: ${succeeded}/${entries.length} succeeded`);
111
+ }
112
+
113
+ function scheduleNext(
114
+ pool: AccountPool,
115
+ cookieJar: CookieJar,
116
+ ): void {
117
+ const config = getConfig();
118
+ const intervalMs = jitter(config.quota.refresh_interval_minutes * 60 * 1000, 0.15);
119
+ _refreshTimer = setTimeout(async () => {
120
+ try {
121
+ await fetchQuotaForAllAccounts(pool, cookieJar, _proxyPool);
122
+ } finally {
123
+ scheduleNext(pool, cookieJar);
124
+ }
125
+ }, intervalMs);
126
+ }
127
+
128
+ /**
129
+ * Start the background quota refresh loop.
130
+ */
131
+ export function startQuotaRefresh(
132
+ accountPool: AccountPool,
133
+ cookieJar: CookieJar,
134
+ proxyPool?: ProxyPool,
135
+ ): void {
136
+ _accountPool = accountPool;
137
+ _cookieJar = cookieJar;
138
+ _proxyPool = proxyPool ?? null;
139
+
140
+ _refreshTimer = setTimeout(async () => {
141
+ try {
142
+ await fetchQuotaForAllAccounts(accountPool, cookieJar, _proxyPool);
143
+ } finally {
144
+ scheduleNext(accountPool, cookieJar);
145
+ }
146
+ }, INITIAL_DELAY_MS);
147
+
148
+ const config = getConfig();
149
+ console.log(`[QuotaRefresh] Scheduled initial quota refresh in 3s (interval: ${config.quota.refresh_interval_minutes}min)`);
150
+ }
151
+
152
+ /**
153
+ * Stop the background refresh timer.
154
+ */
155
+ export function stopQuotaRefresh(): void {
156
+ if (_refreshTimer) {
157
+ clearTimeout(_refreshTimer);
158
+ _refreshTimer = null;
159
+ console.log("[QuotaRefresh] Stopped quota refresh");
160
+ }
161
+ }
src/config.ts CHANGED
@@ -51,6 +51,14 @@ const ConfigSchema = z.object({
51
  transport: z.enum(["auto", "curl-cli", "libcurl-ffi"]).default("auto"),
52
  force_http11: z.boolean().default(false),
53
  }).default({}),
 
 
 
 
 
 
 
 
54
  });
55
 
56
  export type AppConfig = z.infer<typeof ConfigSchema>;
 
51
  transport: z.enum(["auto", "curl-cli", "libcurl-ffi"]).default("auto"),
52
  force_http11: z.boolean().default(false),
53
  }).default({}),
54
+ quota: z.object({
55
+ refresh_interval_minutes: z.number().min(1).default(5),
56
+ warning_thresholds: z.object({
57
+ primary: z.array(z.number().min(1).max(100)).default([80, 90]),
58
+ secondary: z.array(z.number().min(1).max(100)).default([80, 90]),
59
+ }).default({}),
60
+ skip_exhausted: z.boolean().default(true),
61
+ }).default({}),
62
  });
63
 
64
  export type AppConfig = z.infer<typeof ConfigSchema>;
src/index.ts CHANGED
@@ -24,6 +24,7 @@ import { initProxy } from "./tls/curl-binary.js";
24
  import { initTransport } from "./tls/transport.js";
25
  import { loadStaticModels } from "./models/model-store.js";
26
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
 
27
 
28
  export interface ServerHandle {
29
  close: () => Promise<void>;
@@ -126,6 +127,9 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
126
  // Start background model refresh (requires auth to be ready)
127
  startModelRefresh(accountPool, cookieJar, proxyPool);
128
 
 
 
 
129
  // Start proxy health check timer (if proxies exist)
130
  proxyPool.startHealthCheckTimer();
131
 
@@ -145,6 +149,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
145
  stopUpdateChecker();
146
  stopProxyUpdateChecker();
147
  stopModelRefresh();
 
148
  refreshScheduler.destroy();
149
  proxyPool.destroy();
150
  cookieJar.destroy();
 
24
  import { initTransport } from "./tls/transport.js";
25
  import { loadStaticModels } from "./models/model-store.js";
26
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
27
+ import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
28
 
29
  export interface ServerHandle {
30
  close: () => Promise<void>;
 
127
  // Start background model refresh (requires auth to be ready)
128
  startModelRefresh(accountPool, cookieJar, proxyPool);
129
 
130
+ // Start background quota refresh
131
+ startQuotaRefresh(accountPool, cookieJar, proxyPool);
132
+
133
  // Start proxy health check timer (if proxies exist)
134
  proxyPool.startHealthCheckTimer();
135
 
 
149
  stopUpdateChecker();
150
  stopProxyUpdateChecker();
151
  stopModelRefresh();
152
+ stopQuotaRefresh();
153
  refreshScheduler.destroy();
154
  proxyPool.destroy();
155
  cookieJar.destroy();
src/routes/accounts.ts CHANGED
@@ -25,6 +25,8 @@ import type { CodexUsageResponse } from "../proxy/codex-api.js";
25
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
26
  import type { CookieJar } from "../proxy/cookie-jar.js";
27
  import type { ProxyPool } from "../proxy/proxy-pool.js";
 
 
28
 
29
  const BulkImportSchema = z.object({
30
  accounts: z.array(z.object({
@@ -33,38 +35,6 @@ const BulkImportSchema = z.object({
33
  })).min(1),
34
  });
35
 
36
- function toQuota(usage: CodexUsageResponse): CodexQuota {
37
- const sw = usage.rate_limit.secondary_window;
38
- return {
39
- plan_type: usage.plan_type,
40
- rate_limit: {
41
- allowed: usage.rate_limit.allowed,
42
- limit_reached: usage.rate_limit.limit_reached,
43
- used_percent: usage.rate_limit.primary_window?.used_percent ?? null,
44
- reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
45
- limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null,
46
- },
47
- secondary_rate_limit: sw
48
- ? {
49
- limit_reached: usage.rate_limit.limit_reached,
50
- used_percent: sw.used_percent ?? null,
51
- reset_at: sw.reset_at ?? null,
52
- limit_window_seconds: sw.limit_window_seconds ?? null,
53
- }
54
- : null,
55
- code_review_rate_limit: usage.code_review_rate_limit
56
- ? {
57
- allowed: usage.code_review_rate_limit.allowed,
58
- limit_reached: usage.code_review_rate_limit.limit_reached,
59
- used_percent:
60
- usage.code_review_rate_limit.primary_window?.used_percent ?? null,
61
- reset_at:
62
- usage.code_review_rate_limit.primary_window?.reset_at ?? null,
63
- }
64
- : null,
65
- };
66
- }
67
-
68
  export function createAccountRoutes(
69
  pool: AccountPool,
70
  scheduler: RefreshScheduler,
@@ -143,53 +113,72 @@ export function createAccountRoutes(
143
  return c.json({ success: true, added, updated, failed, errors });
144
  });
145
 
146
- // List all accounts (with optional ?quota=true)
 
 
147
  app.get("/auth/accounts", async (c) => {
148
- const accounts = pool.getAccounts();
149
- const wantQuota = c.req.query("quota") === "true";
150
-
151
- if (!wantQuota) {
152
- const enrichedBasic = accounts.map((acct) => ({
153
- ...acct,
154
- proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
155
- proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
156
- }));
157
- return c.json({ accounts: enrichedBasic });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  }
159
 
160
- // Fetch quota for every active account in parallel
161
- const enriched: AccountInfo[] = await Promise.all(
162
- accounts.map(async (acct) => {
163
- if (acct.status !== "active") return acct;
164
-
165
- const entry = pool.getEntry(acct.id);
166
- if (!entry) return acct;
167
-
168
- try {
169
- const api = makeApi(acct.id, entry.token, entry.accountId);
170
- const usage = await api.getUsage();
171
- // Sync rate limit window — auto-reset local counters on window rollover
172
- const resetAt = usage.rate_limit.primary_window?.reset_at ?? null;
173
- const windowSec = usage.rate_limit.primary_window?.limit_window_seconds ?? null;
174
- pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
175
- // Re-read usage after potential reset
176
- const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
177
- return {
178
- ...freshAcct,
179
- quota: toQuota(usage),
180
- proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
181
- proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
182
- };
183
- } catch {
184
- return {
185
- ...acct,
186
- proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
187
- proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
188
- };
189
- }
190
- }),
191
- );
192
-
193
  return c.json({ accounts: enriched });
194
  });
195
 
@@ -334,5 +323,14 @@ export function createAccountRoutes(
334
  return c.json({ success: true });
335
  });
336
 
 
 
 
 
 
 
 
 
 
337
  return app;
338
  }
 
25
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
26
  import type { CookieJar } from "../proxy/cookie-jar.js";
27
  import type { ProxyPool } from "../proxy/proxy-pool.js";
28
+ import { toQuota } from "../auth/quota-utils.js";
29
+ import { getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js";
30
 
31
  const BulkImportSchema = z.object({
32
  accounts: z.array(z.object({
 
35
  })).min(1),
36
  });
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  export function createAccountRoutes(
39
  pool: AccountPool,
40
  scheduler: RefreshScheduler,
 
113
  return c.json({ success: true, added, updated, failed, errors });
114
  });
115
 
116
+ // List all accounts
117
+ // ?quota=true → return cached quota (fast, from background refresh)
118
+ // ?quota=fresh → force live fetch from upstream (manual refresh button)
119
  app.get("/auth/accounts", async (c) => {
120
+ const quotaParam = c.req.query("quota");
121
+ const wantFresh = quotaParam === "fresh";
122
+
123
+ if (wantFresh) {
124
+ // Live fetch quota for every active account in parallel
125
+ const accounts = pool.getAccounts();
126
+ const enriched: AccountInfo[] = await Promise.all(
127
+ accounts.map(async (acct) => {
128
+ if (acct.status !== "active") {
129
+ return {
130
+ ...acct,
131
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
132
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
133
+ };
134
+ }
135
+
136
+ const entry = pool.getEntry(acct.id);
137
+ if (!entry) {
138
+ return {
139
+ ...acct,
140
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
141
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
142
+ };
143
+ }
144
+
145
+ try {
146
+ const api = makeApi(acct.id, entry.token, entry.accountId);
147
+ const usage = await api.getUsage();
148
+ const quota = toQuota(usage);
149
+ // Cache the fresh quota
150
+ pool.updateCachedQuota(acct.id, quota);
151
+ // Sync rate limit window — auto-reset local counters on window rollover
152
+ const resetAt = usage.rate_limit.primary_window?.reset_at ?? null;
153
+ const windowSec = usage.rate_limit.primary_window?.limit_window_seconds ?? null;
154
+ pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
155
+ // Re-read usage after potential reset
156
+ const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
157
+ return {
158
+ ...freshAcct,
159
+ quota,
160
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
161
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
162
+ };
163
+ } catch {
164
+ return {
165
+ ...acct,
166
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
167
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
168
+ };
169
+ }
170
+ }),
171
+ );
172
+ return c.json({ accounts: enriched });
173
  }
174
 
175
+ // Default: return accounts with cached quota (populated by toInfo())
176
+ const accounts = pool.getAccounts();
177
+ const enriched = accounts.map((acct) => ({
178
+ ...acct,
179
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
180
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
181
+ }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  return c.json({ accounts: enriched });
183
  });
184
 
 
323
  return c.json({ success: true });
324
  });
325
 
326
+ // ── Quota warnings ──────────────────────────────────────────────
327
+
328
+ app.get("/auth/quota/warnings", (c) => {
329
+ return c.json({
330
+ warnings: getActiveWarnings(),
331
+ updatedAt: getWarningsLastUpdated(),
332
+ });
333
+ });
334
+
335
  return app;
336
  }
web/src/components/AccountList.tsx CHANGED
@@ -1,8 +1,8 @@
1
- import { useState, useCallback } from "preact/hooks";
2
  import { useI18n, useT } from "../../../shared/i18n/context";
3
  import { AccountCard } from "./AccountCard";
4
  import { AccountImportExport } from "./AccountImportExport";
5
- import type { Account, ProxyEntry } from "../../../shared/types";
6
 
7
  interface AccountListProps {
8
  accounts: Account[];
@@ -21,6 +21,21 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
21
  const t = useT();
22
  const { lang } = useI18n();
23
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  const toggleSelect = useCallback((id: string) => {
26
  setSelectedIds((prev) => {
@@ -91,6 +106,27 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
91
  </button>
92
  </div>
93
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
95
  {loading ? (
96
  <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
 
1
+ import { useState, useCallback, useEffect } from "preact/hooks";
2
  import { useI18n, useT } from "../../../shared/i18n/context";
3
  import { AccountCard } from "./AccountCard";
4
  import { AccountImportExport } from "./AccountImportExport";
5
+ import type { Account, ProxyEntry, QuotaWarning } from "../../../shared/types";
6
 
7
  interface AccountListProps {
8
  accounts: Account[];
 
21
  const t = useT();
22
  const { lang } = useI18n();
23
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
24
+ const [warnings, setWarnings] = useState<QuotaWarning[]>([]);
25
+
26
+ // Poll quota warnings
27
+ useEffect(() => {
28
+ const fetchWarnings = async () => {
29
+ try {
30
+ const resp = await fetch("/auth/quota/warnings");
31
+ const data = await resp.json();
32
+ setWarnings(data.warnings || []);
33
+ } catch { /* ignore */ }
34
+ };
35
+ fetchWarnings();
36
+ const timer = setInterval(fetchWarnings, 30_000);
37
+ return () => clearInterval(timer);
38
+ }, []);
39
 
40
  const toggleSelect = useCallback((id: string) => {
41
  setSelectedIds((prev) => {
 
106
  </button>
107
  </div>
108
  </div>
109
+ {/* Quota warning banners */}
110
+ {warnings.filter((w) => w.level === "critical").length > 0 && (
111
+ <div class="px-4 py-2.5 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/30 text-red-700 dark:text-red-400 text-sm flex items-center gap-2">
112
+ <svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
113
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
114
+ </svg>
115
+ <span>
116
+ {t("quotaCriticalWarning").replace("{count}", String(warnings.filter((w) => w.level === "critical").length))}
117
+ </span>
118
+ </div>
119
+ )}
120
+ {warnings.filter((w) => w.level === "warning").length > 0 && warnings.filter((w) => w.level === "critical").length === 0 && (
121
+ <div class="px-4 py-2.5 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/30 text-amber-700 dark:text-amber-400 text-sm flex items-center gap-2">
122
+ <svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
123
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
124
+ </svg>
125
+ <span>
126
+ {t("quotaWarning").replace("{count}", String(warnings.filter((w) => w.level === "warning").length))}
127
+ </span>
128
+ </div>
129
+ )}
130
  <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
131
  {loading ? (
132
  <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">