Spaces:
Paused
Paused
Merge pull request #116 from icebear0828/fix/quota-exhausted-routing
Browse files- CHANGELOG.md +3 -0
- README.md +4 -0
- config/prompts/automation-response.md +1 -4
- package-lock.json +3 -3
- src/auth/__tests__/account-pool-quota.test.ts +46 -1
- src/auth/account-pool.ts +10 -2
- src/auth/usage-refresher.ts +2 -2
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 |
+
[](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.
|
| 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.
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
"name": "codex-proxy",
|
| 9 |
-
"version": "1.0.
|
| 10 |
"hasInstallScript": true,
|
| 11 |
"workspaces": [
|
| 12 |
"packages/*"
|
|
@@ -7364,7 +7364,7 @@
|
|
| 7364 |
},
|
| 7365 |
"packages/electron": {
|
| 7366 |
"name": "@codex-proxy/electron",
|
| 7367 |
-
"version": "1.0.
|
| 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
|
| 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 |
-
//
|
| 324 |
-
if (entry.status
|
| 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) => {
|