Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
b3dfc6c
1
Parent(s): 9d6278d
feat: add per-account empty response tracking with email in logs
Browse filesTrack empty_response_count per account in AccountUsage, exposed via
GET /auth/accounts. Logs now include account email for easier debugging.
Counter resets on usage reset and rate limit window rollover.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CHANGELOG.md +3 -0
- src/auth/account-pool.ts +19 -0
- src/auth/types.ts +1 -0
- src/routes/shared/proxy-handler.ts +6 -2
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
| 8 |
|
| 9 |
### Added
|
| 10 |
|
|
|
|
|
|
|
| 11 |
- 空响应检测 + 自动换号重试:Codex API 返回 HTTP 200 但无内容时,非流式自动切换账号重试(最多 3 次),流式注入错误提示文本
|
| 12 |
- 自动提取 Chromium 版本:`extract-fingerprint.ts` 从 `package.json` 读取 Electron 版本,通过 `electron-to-chromium` 映射为 Chromium 大版本,`apply-update.ts` 自动更新 `chromium_version` 和 TLS impersonate profile
|
| 13 |
- 动态模型列表:后台从 Codex 后端自动获取模型目录,与静态 YAML 合并(`src/models/model-store.ts`、`src/models/model-fetcher.ts`)
|
|
@@ -28,6 +30,7 @@
|
|
| 28 |
|
| 29 |
### Fixed
|
| 30 |
|
|
|
|
| 31 |
- `apply-update.ts` 模型比较不再误报删除:静态提取只含 2 个硬编码模型,与 YAML 的 24 个比较会产生 22 个假删除,现在只报新增
|
| 32 |
- `update-checker.ts` 子进程超时保护:`fork()` 添加 5 分钟 kill timer,防止挂起导致 `_updateInProgress` 永久锁定
|
| 33 |
- `model-fetcher.ts` 初始定时器添加 try/finally,防止异常中断刷新循环
|
|
|
|
| 8 |
|
| 9 |
### Added
|
| 10 |
|
| 11 |
+
- 空响应计数器:每个账号追踪 `empty_response_count`,通过 `GET /auth/accounts` 可查看,窗口重置时自动归零
|
| 12 |
+
- 空响应日志增强:日志中显示账号邮箱(`Account xxxx (email) | Empty response`),便于定位问题账号
|
| 13 |
- 空响应检测 + 自动换号重试:Codex API 返回 HTTP 200 但无内容时,非流式自动切换账号重试(最多 3 次),流式注入错误提示文本
|
| 14 |
- 自动提取 Chromium 版本:`extract-fingerprint.ts` 从 `package.json` 读取 Electron 版本,通过 `electron-to-chromium` 映射为 Chromium 大版本,`apply-update.ts` 自动更新 `chromium_version` 和 TLS impersonate profile
|
| 15 |
- 动态模型列表:后台从 Codex 后端自动获取模型目录,与静态 YAML 合并(`src/models/model-store.ts`、`src/models/model-fetcher.ts`)
|
|
|
|
| 30 |
|
| 31 |
### Fixed
|
| 32 |
|
| 33 |
+
- 空响应重试循环中账号双重释放:外层 catch 使用原始 `entryId` 而非当前活跃账号,导致换号重试失败时 double-release(`proxy-handler.ts`)
|
| 34 |
- `apply-update.ts` 模型比较不再误报删除:静态提取只含 2 个硬编码模型,与 YAML 的 24 个比较会产生 22 个假删除,现在只报新增
|
| 35 |
- `update-checker.ts` 子进程超时保护:`fork()` 添加 5 分钟 kill timer,防止挂起导致 `_updateInProgress` 永久锁定
|
| 36 |
- `model-fetcher.ts` 初始定时器添加 try/finally,防止异常中断刷新循环
|
src/auth/account-pool.ts
CHANGED
|
@@ -203,6 +203,7 @@ export class AccountPool {
|
|
| 203 |
request_count: 0,
|
| 204 |
input_tokens: 0,
|
| 205 |
output_tokens: 0,
|
|
|
|
| 206 |
last_used: null,
|
| 207 |
rate_limit_until: null,
|
| 208 |
},
|
|
@@ -214,6 +215,16 @@ export class AccountPool {
|
|
| 214 |
return id;
|
| 215 |
}
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
removeAccount(id: string): boolean {
|
| 218 |
this.acquireLocks.delete(id);
|
| 219 |
const deleted = this.accounts.delete(id);
|
|
@@ -254,6 +265,7 @@ export class AccountPool {
|
|
| 254 |
request_count: 0,
|
| 255 |
input_tokens: 0,
|
| 256 |
output_tokens: 0,
|
|
|
|
| 257 |
last_used: null,
|
| 258 |
rate_limit_until: null,
|
| 259 |
window_reset_at: entry.usage.window_reset_at ?? null,
|
|
@@ -279,6 +291,7 @@ export class AccountPool {
|
|
| 279 |
entry.usage.request_count = 0;
|
| 280 |
entry.usage.input_tokens = 0;
|
| 281 |
entry.usage.output_tokens = 0;
|
|
|
|
| 282 |
entry.usage.last_used = null;
|
| 283 |
entry.usage.rate_limit_until = null;
|
| 284 |
}
|
|
@@ -466,6 +479,11 @@ export class AccountPool {
|
|
| 466 |
needsPersist = true;
|
| 467 |
}
|
| 468 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
this.accounts.set(entry.id, entry);
|
| 470 |
}
|
| 471 |
}
|
|
@@ -505,6 +523,7 @@ export class AccountPool {
|
|
| 505 |
request_count: 0,
|
| 506 |
input_tokens: 0,
|
| 507 |
output_tokens: 0,
|
|
|
|
| 508 |
last_used: null,
|
| 509 |
rate_limit_until: null,
|
| 510 |
},
|
|
|
|
| 203 |
request_count: 0,
|
| 204 |
input_tokens: 0,
|
| 205 |
output_tokens: 0,
|
| 206 |
+
empty_response_count: 0,
|
| 207 |
last_used: null,
|
| 208 |
rate_limit_until: null,
|
| 209 |
},
|
|
|
|
| 215 |
return id;
|
| 216 |
}
|
| 217 |
|
| 218 |
+
/**
|
| 219 |
+
* Record an empty response for an account (HTTP 200 but zero text deltas).
|
| 220 |
+
*/
|
| 221 |
+
recordEmptyResponse(entryId: string): void {
|
| 222 |
+
const entry = this.accounts.get(entryId);
|
| 223 |
+
if (!entry) return;
|
| 224 |
+
entry.usage.empty_response_count++;
|
| 225 |
+
this.schedulePersist();
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
removeAccount(id: string): boolean {
|
| 229 |
this.acquireLocks.delete(id);
|
| 230 |
const deleted = this.accounts.delete(id);
|
|
|
|
| 265 |
request_count: 0,
|
| 266 |
input_tokens: 0,
|
| 267 |
output_tokens: 0,
|
| 268 |
+
empty_response_count: 0,
|
| 269 |
last_used: null,
|
| 270 |
rate_limit_until: null,
|
| 271 |
window_reset_at: entry.usage.window_reset_at ?? null,
|
|
|
|
| 291 |
entry.usage.request_count = 0;
|
| 292 |
entry.usage.input_tokens = 0;
|
| 293 |
entry.usage.output_tokens = 0;
|
| 294 |
+
entry.usage.empty_response_count = 0;
|
| 295 |
entry.usage.last_used = null;
|
| 296 |
entry.usage.rate_limit_until = null;
|
| 297 |
}
|
|
|
|
| 479 |
needsPersist = true;
|
| 480 |
}
|
| 481 |
}
|
| 482 |
+
// Backfill empty_response_count for old entries
|
| 483 |
+
if (entry.usage.empty_response_count == null) {
|
| 484 |
+
entry.usage.empty_response_count = 0;
|
| 485 |
+
needsPersist = true;
|
| 486 |
+
}
|
| 487 |
this.accounts.set(entry.id, entry);
|
| 488 |
}
|
| 489 |
}
|
|
|
|
| 523 |
request_count: 0,
|
| 524 |
input_tokens: 0,
|
| 525 |
output_tokens: 0,
|
| 526 |
+
empty_response_count: 0,
|
| 527 |
last_used: null,
|
| 528 |
rate_limit_until: null,
|
| 529 |
},
|
src/auth/types.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface AccountUsage {
|
|
| 13 |
request_count: number;
|
| 14 |
input_tokens: number;
|
| 15 |
output_tokens: number;
|
|
|
|
| 16 |
last_used: string | null;
|
| 17 |
rate_limit_until: string | null;
|
| 18 |
/** Tracks the current rate limit window end (Unix seconds). When window rolls over, counters reset. */
|
|
|
|
| 13 |
request_count: number;
|
| 14 |
input_tokens: number;
|
| 15 |
output_tokens: number;
|
| 16 |
+
empty_response_count: number;
|
| 17 |
last_used: string | null;
|
| 18 |
rate_limit_until: string | null;
|
| 19 |
/** Tracks the current rate limit window end (Unix seconds). When window rolls over, counters reset. */
|
src/routes/shared/proxy-handler.ts
CHANGED
|
@@ -174,9 +174,11 @@ export async function handleProxyRequest(
|
|
| 174 |
return c.json(result.response);
|
| 175 |
} catch (collectErr) {
|
| 176 |
if (collectErr instanceof EmptyResponseError && attempt <= MAX_EMPTY_RETRIES) {
|
|
|
|
| 177 |
console.warn(
|
| 178 |
-
`[${fmt.tag}] Account ${currentEntryId} | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), switching account...`,
|
| 179 |
);
|
|
|
|
| 180 |
accountPool.release(currentEntryId, collectErr.usage);
|
| 181 |
|
| 182 |
// Acquire a new account
|
|
@@ -210,9 +212,11 @@ export async function handleProxyRequest(
|
|
| 210 |
// Not an empty response error, or retries exhausted
|
| 211 |
accountPool.release(currentEntryId);
|
| 212 |
if (collectErr instanceof EmptyResponseError) {
|
|
|
|
| 213 |
console.warn(
|
| 214 |
-
`[${fmt.tag}] Account ${currentEntryId} | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), all retries exhausted`,
|
| 215 |
);
|
|
|
|
| 216 |
c.status(502);
|
| 217 |
return c.json(fmt.formatError(502, "Codex returned empty responses across all available accounts"));
|
| 218 |
}
|
|
|
|
| 174 |
return c.json(result.response);
|
| 175 |
} catch (collectErr) {
|
| 176 |
if (collectErr instanceof EmptyResponseError && attempt <= MAX_EMPTY_RETRIES) {
|
| 177 |
+
const emptyEmail = accountPool.getEntry(currentEntryId)?.email ?? "?";
|
| 178 |
console.warn(
|
| 179 |
+
`[${fmt.tag}] Account ${currentEntryId} (${emptyEmail}) | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), switching account...`,
|
| 180 |
);
|
| 181 |
+
accountPool.recordEmptyResponse(currentEntryId);
|
| 182 |
accountPool.release(currentEntryId, collectErr.usage);
|
| 183 |
|
| 184 |
// Acquire a new account
|
|
|
|
| 212 |
// Not an empty response error, or retries exhausted
|
| 213 |
accountPool.release(currentEntryId);
|
| 214 |
if (collectErr instanceof EmptyResponseError) {
|
| 215 |
+
const exhaustedEmail = accountPool.getEntry(currentEntryId)?.email ?? "?";
|
| 216 |
console.warn(
|
| 217 |
+
`[${fmt.tag}] Account ${currentEntryId} (${exhaustedEmail}) | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), all retries exhausted`,
|
| 218 |
);
|
| 219 |
+
accountPool.recordEmptyResponse(currentEntryId);
|
| 220 |
c.status(502);
|
| 221 |
return c.json(fmt.formatError(502, "Codex returned empty responses across all available accounts"));
|
| 222 |
}
|