icebear commited on
Commit
e13eb0a
·
unverified ·
2 Parent(s): b77f2f0261b199

Merge pull request #116 from icebear0828/fix/quota-exhausted-routing

Browse files
CHANGELOG.md CHANGED
@@ -8,6 +8,9 @@
8
 
9
  ### Fixed
10
 
 
 
 
11
  - `/v1/responses` 不再强制要求 `instructions` 字段,未传时默认空字符串(#71)
12
  - 修复 Cherry 等第三方客户端不传 `instructions` 时返回 400 的兼容性问题
13
  - CI 构建修复:WebSocket 传输 `instructions` 类型不匹配(TS2322)导致 Electron/Docker 编译失败
 
8
 
9
  ### Fixed
10
 
11
+ - 额度耗尽账号仍显示「活跃」并接收请求的问题(#115)
12
+ - `markQuotaExhausted()` 现在可以覆盖 `rate_limited` 状态(仅延长,不缩短 reset 时间)
13
+ - 后台额度刷新现在同时检查 `rate_limited` 账号,防止因 429 短暂 backoff 导致漏检
14
  - `/v1/responses` 不再强制要求 `instructions` 字段,未传时默认空字符串(#71)
15
  - 修复 Cherry 等第三方客户端不传 `instructions` 时返回 400 的兼容性问题
16
  - CI 构建修复:WebSocket 传输 `instructions` 类型不匹配(TS2322)导致 Electron/Docker 编译失败
README.md CHANGED
@@ -481,6 +481,10 @@ server:
481
  - 模型响应中自动过滤 Codex Desktop 指令
482
  <!-- CHANGELOG:END -->
483
 
 
 
 
 
484
  ## 📄 许可协议 (License)
485
 
486
  本项目采用 **非商业许可 (Non-Commercial)**:
 
481
  - 模型响应中自动过滤 Codex Desktop 指令
482
  <!-- CHANGELOG:END -->
483
 
484
+ ## ⭐ Star History
485
+
486
+ [![Star History Chart](https://api.star-history.com/svg?repos=icebear0828/codex-proxy&type=Date)](https://star-history.com/#icebear0828/codex-proxy&Date)
487
+
488
  ## 📄 许可协议 (License)
489
 
490
  本项目采用 **非商业许可 (Non-Commercial)**:
config/prompts/automation-response.md CHANGED
@@ -11,19 +11,16 @@ Response MUST end with a remark-directive block.
11
  - REQUIRED: End with a valid remark-directive block on its own line (not inline).
12
  - Always include an inbox item directive:
13
  \`::inbox-item{title="Sample title" summary="Place description here"}\`
14
- - If you want to close the thread, add an archive directive on its own line after the inbox item:
15
- \`::archive-thread\`
16
 
17
  ## Choosing return value
18
 
19
  - For recurring/bg threads (e.g., "pull datadog logs and fix any new bugs", "address the PR comments"):
20
  - Always return \`::inbox-item{...}\` with the title/summary the user should see.
21
- - Only add \`::archive-thread\` when there is nothing actionable or new to show. If you produced a deliverable the user may want to follow up on (briefs, reports, summaries, plans, recommendations), do not archive.
22
 
23
  ## Guidelines
24
 
25
  - Directives MUST be on their own line.
26
- - Output exactly ONE inbox-item directive. Archive-thread is optional.
27
  - Do NOT use invalid remark-directive formatting.
28
  - DO NOT place commas between arguments.
29
  - Valid: \`::inbox-item{title="Sample title" summary="Place description here"}\`
 
11
  - REQUIRED: End with a valid remark-directive block on its own line (not inline).
12
  - Always include an inbox item directive:
13
  \`::inbox-item{title="Sample title" summary="Place description here"}\`
 
 
14
 
15
  ## Choosing return value
16
 
17
  - For recurring/bg threads (e.g., "pull datadog logs and fix any new bugs", "address the PR comments"):
18
  - Always return \`::inbox-item{...}\` with the title/summary the user should see.
 
19
 
20
  ## Guidelines
21
 
22
  - Directives MUST be on their own line.
23
+ - Output exactly ONE inbox-item directive.
24
  - Do NOT use invalid remark-directive formatting.
25
  - DO NOT place commas between arguments.
26
  - Valid: \`::inbox-item{title="Sample title" summary="Place description here"}\`
package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "name": "codex-proxy",
3
- "version": "1.0.59",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
- "version": "1.0.59",
10
  "hasInstallScript": true,
11
  "workspaces": [
12
  "packages/*"
@@ -7364,7 +7364,7 @@
7364
  },
7365
  "packages/electron": {
7366
  "name": "@codex-proxy/electron",
7367
- "version": "1.0.58",
7368
  "dependencies": {
7369
  "electron-updater": "^6.3.0"
7370
  },
 
1
  {
2
  "name": "codex-proxy",
3
+ "version": "1.0.67",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
+ "version": "1.0.67",
10
  "hasInstallScript": true,
11
  "workspaces": [
12
  "packages/*"
 
7364
  },
7365
  "packages/electron": {
7366
  "name": "@codex-proxy/electron",
7367
+ "version": "1.0.67",
7368
  "dependencies": {
7369
  "electron-updater": "^6.3.0"
7370
  },
src/auth/__tests__/account-pool-quota.test.ts CHANGED
@@ -123,7 +123,7 @@ describe("AccountPool quota methods", () => {
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
 
@@ -132,6 +132,51 @@ describe("AccountPool quota methods", () => {
132
  const entry = pool.getEntry(id);
133
  expect(entry?.status).toBe("disabled"); // unchanged
134
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  });
136
 
137
  describe("toInfo with cached quota", () => {
 
123
  expect(entry?.usage.rate_limit_until).toBeTruthy();
124
  });
125
 
126
+ it("does not override disabled status", () => {
127
  const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ddd");
128
  pool.markStatus(id, "disabled");
129
 
 
132
  const entry = pool.getEntry(id);
133
  expect(entry?.status).toBe("disabled"); // unchanged
134
  });
135
+
136
+ it("does not override expired status", () => {
137
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ddd2");
138
+ pool.markStatus(id, "expired");
139
+
140
+ pool.markQuotaExhausted(id, Math.floor(Date.now() / 1000) + 3600);
141
+
142
+ const entry = pool.getEntry(id);
143
+ expect(entry?.status).toBe("expired"); // unchanged
144
+ });
145
+
146
+ it("extends rate_limit_until on already rate_limited account", () => {
147
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ddd3");
148
+ // Simulate 429 backoff (short)
149
+ pool.markRateLimited(id, { retryAfterSec: 60 });
150
+ const entryBefore = pool.getEntry(id);
151
+ expect(entryBefore?.status).toBe("rate_limited");
152
+ const shortUntil = new Date(entryBefore!.usage.rate_limit_until!).getTime();
153
+
154
+ // Quota refresh discovers exhaustion — much longer reset
155
+ const resetAt = Math.floor(Date.now() / 1000) + 7200; // 2 hours
156
+ pool.markQuotaExhausted(id, resetAt);
157
+
158
+ const entryAfter = pool.getEntry(id);
159
+ expect(entryAfter?.status).toBe("rate_limited");
160
+ const longUntil = new Date(entryAfter!.usage.rate_limit_until!).getTime();
161
+ expect(longUntil).toBeGreaterThan(shortUntil);
162
+ });
163
+
164
+ it("does not shorten existing rate_limit_until", () => {
165
+ const id = pool.addAccount("eyJhbGciOiJIUzI1NiJ9.test-token-ddd4");
166
+ // Mark with long reset (e.g. 7-day quota)
167
+ const longResetAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours
168
+ pool.markQuotaExhausted(id, longResetAt);
169
+
170
+ const entryBefore = pool.getEntry(id);
171
+ const originalUntil = entryBefore!.usage.rate_limit_until;
172
+
173
+ // Try to mark with shorter reset (e.g. 5-hour quota)
174
+ const shortResetAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour
175
+ pool.markQuotaExhausted(id, shortResetAt);
176
+
177
+ const entryAfter = pool.getEntry(id);
178
+ expect(entryAfter!.usage.rate_limit_until).toBe(originalUntil); // unchanged
179
+ });
180
  });
181
 
182
  describe("toInfo with cached quota", () => {
src/auth/account-pool.ts CHANGED
@@ -320,12 +320,20 @@ export class AccountPool {
320
  markQuotaExhausted(entryId: string, resetAtUnix: number | null): void {
321
  const entry = this.accounts.get(entryId);
322
  if (!entry) return;
323
- // Only mark if currently active — don't override other states
324
- if (entry.status !== "active") return;
325
 
326
  const until = resetAtUnix
327
  ? new Date(resetAtUnix * 1000).toISOString()
328
  : new Date(Date.now() + 300_000).toISOString(); // fallback 5 min
 
 
 
 
 
 
 
 
329
  entry.status = "rate_limited";
330
  entry.usage.rate_limit_until = until;
331
  this.acquireLocks.delete(entryId);
 
320
  markQuotaExhausted(entryId: string, resetAtUnix: number | null): void {
321
  const entry = this.accounts.get(entryId);
322
  if (!entry) return;
323
+ // Don't override disabled or expired states
324
+ if (entry.status === "disabled" || entry.status === "expired") return;
325
 
326
  const until = resetAtUnix
327
  ? new Date(resetAtUnix * 1000).toISOString()
328
  : new Date(Date.now() + 300_000).toISOString(); // fallback 5 min
329
+
330
+ // Only extend rate_limit_until, never shorten it
331
+ if (entry.status === "rate_limited" && entry.usage.rate_limit_until) {
332
+ const existing = new Date(entry.usage.rate_limit_until).getTime();
333
+ const proposed = new Date(until).getTime();
334
+ if (proposed <= existing) return;
335
+ }
336
+
337
  entry.status = "rate_limited";
338
  entry.usage.rate_limit_until = until;
339
  this.acquireLocks.delete(entryId);
src/auth/usage-refresher.ts CHANGED
@@ -35,13 +35,13 @@ async function fetchQuotaForAllAccounts(
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) => {
 
35
  ): Promise<void> {
36
  if (!pool.isAuthenticated()) return;
37
 
38
+ const entries = pool.getAllEntries().filter((e) => e.status === "active" || e.status === "rate_limited");
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/rate-limited account(s)`);
45
 
46
  const results = await Promise.allSettled(
47
  entries.map(async (entry) => {