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 files

Track 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 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
  }