Spaces:
Paused
Paused
icebear icebear0828 commited on
feat: auto-refresh quota with tiered warnings (#92) (#93)
Browse filesBackground 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 +1 -0
- config/default.yaml +6 -0
- package.json +3 -0
- shared/hooks/use-accounts.ts +10 -3
- shared/i18n/translations.ts +4 -0
- shared/types.ts +19 -6
- src/auth/__tests__/account-pool-quota.test.ts +181 -0
- src/auth/__tests__/config-quota.test.ts +71 -0
- src/auth/__tests__/quota-utils.test.ts +100 -0
- src/auth/__tests__/quota-warnings.test.ts +117 -0
- src/auth/account-pool.ts +47 -1
- src/auth/quota-utils.ts +39 -0
- src/auth/quota-warnings.ts +73 -0
- src/auth/types.ts +5 -0
- src/auth/usage-refresher.ts +161 -0
- src/config.ts +8 -0
- src/index.ts +5 -0
- src/routes/accounts.ts +74 -76
- web/src/components/AccountList.tsx +38 -2
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
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
| 147 |
app.get("/auth/accounts", async (c) => {
|
| 148 |
-
const
|
| 149 |
-
const
|
| 150 |
-
|
| 151 |
-
if (
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
-
//
|
| 161 |
-
const
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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">
|