@rikcodes/teamclaude 1.1.20-rik.3 → 1.1.20-rik.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.3",
3
+ "version": "1.1.20-rik.5",
4
4
  "description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  updateAccountEntry,
20
20
  canUpsertOAuthAccount,
21
21
  oauthIdentityFields, duplicateNameWarnings } from './identity.js';
22
+ import { routeReachabilityWarnings } from './route-warnings.js';
22
23
  import { resolveAccounts } from './resolve-accounts.js';
23
24
  import { loginCodex } from './codex-auth.js';
24
25
  import { syncAccountsFromDisk } from './sync-accounts.js';
@@ -290,6 +291,10 @@ async function serverCommand() {
290
291
  // one name, and every name lookup becomes ambiguous. Said once at startup and
291
292
  // again after a reload, never fatal.
292
293
  for (const line of duplicateNameWarnings(accounts)) console.error(line);
294
+ // Same shape, same reason: route membership is by name while eligibility is by
295
+ // provider, so a route can list only accounts that cannot serve the request
296
+ // that arrives — and every readout still shows it healthy.
297
+ for (const line of routeReachabilityWarnings(config.routes, accounts)) console.error(line);
293
298
  const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, projection: config.projection, expiryRouting: config.expiryRouting, adaptive });
294
299
  // Names the activity log's session column from Claude Code's own on-disk
295
300
  // session titles. Built whether or not the TUI runs, so a reload has one
@@ -413,6 +418,9 @@ async function serverCommand() {
413
418
  // Pick up route table edits (teamclaude route …, TUI editor, or a hand edit).
414
419
  config.routes = diskConfig.routes || [];
415
420
  accountManager.setRoutes(config.routes);
421
+ // After setRoutes, not before: a route edit is the whole reason this check
422
+ // exists, and reading the table it replaced would miss exactly that.
423
+ for (const line of routeReachabilityWarnings(config.routes, accountManager.accounts)) console.error(line);
416
424
  // Pick up a distributeSessions change (hand edit or another writer) the same
417
425
  // way routes, sx, probe and warmup are picked up below.
418
426
  // Not coerced to a boolean: 'adaptive' is a third mode, and !! would flatten
package/src/provider.js CHANGED
@@ -24,6 +24,11 @@ export const PROVIDERS = {
24
24
  // Anthropic pins the account inside the request body (metadata.user_id),
25
25
  // so the body rewrites apply here and only here.
26
26
  rewritesBody: true,
27
+ // Claude Code waits for the first response byte for as long as its own
28
+ // API_TIMEOUT_MS allows, which is generous. That is what lets the proxy
29
+ // wait out a short retry-after, or poll for an account to recover, without
30
+ // the client ever seeing the 429.
31
+ holdsConnection: true,
27
32
  },
28
33
  codex: {
29
34
  id: 'codex',
@@ -36,6 +41,13 @@ export const PROVIDERS = {
36
41
  // needed — and the Anthropic-specific tool-pair repair would be wrong to
37
42
  // apply to a Responses API body.
38
43
  rewritesBody: false,
44
+ // A Codex client gives the response head a fixed 60s and then retries the
45
+ // whole request, about four times, before failing. None of that is visible
46
+ // from here, so a wait we intended as "absorb this for the client" reads to
47
+ // it as a hang: it abandons the attempt we are still holding, retries into
48
+ // the same wait, and turns one reportable 429 into a ~250s silent stall and
49
+ // then a storm of them. Answer it instead and let it back off knowing why.
50
+ holdsConnection: false,
39
51
  },
40
52
  };
41
53
 
@@ -201,6 +213,25 @@ export function upstreamFor(account, configuredUpstream) {
201
213
  return configuredUpstream || PROVIDERS.anthropic.upstream;
202
214
  }
203
215
 
216
+ /**
217
+ * Whether the proxy may hold a request on the connection — waiting out a
218
+ * retry-after, or polling for an account to recover — instead of answering now.
219
+ *
220
+ * Holding is only invisible to a client that waits longer than we do. That is a
221
+ * property of the client, and the request path is what we know about it: every
222
+ * caller on the Codex path speaks the Codex protocol and brings its own fixed
223
+ * deadline with it, whether it is the Codex CLI or a translating sidecar's back
224
+ * leg. Keyed on the provider rather than on the caller's address, because a
225
+ * loopback peer does not narrow it — Claude Code is loopback too.
226
+ *
227
+ * Only the WAIT is withheld. Pausing the account, so concurrent requests avoid
228
+ * it, still happens; the client is simply told now, with the retry-after it
229
+ * needs to act on.
230
+ */
231
+ export function holdsConnection(provider) {
232
+ return PROVIDERS[provider && PROVIDERS[provider] ? provider : DEFAULT_PROVIDER].holdsConnection;
233
+ }
234
+
204
235
  /** Whether the Anthropic-only body rewrites apply to this account. */
205
236
  export function rewritesBody(account) {
206
237
  return PROVIDERS[providerOf(account)].rewritesBody;
@@ -0,0 +1,60 @@
1
+ // Config coherence a route cannot check for itself.
2
+ //
3
+ // Route membership is by NAME, but eligibility is by PROVIDER — and the two
4
+ // disagree silently. A route may name three accounts, every one of them healthy
5
+ // in `teamclaude status`, and still be unable to answer the request that
6
+ // actually arrives, because the arriving path decides which of them are even
7
+ // candidates.
8
+
9
+ import { providerOf, isSubscriptionAccount, DEFAULT_PROVIDER } from './provider.js';
10
+
11
+ /**
12
+ * Whether `account` is a candidate for a request on `provider`'s path.
13
+ *
14
+ * Mirrors the partition selection applies (`_excludeOtherProviders`): only
15
+ * SUBSCRIPTION accounts are fenced off by provider, because a Claude Max token
16
+ * is issued to Claude and a ChatGPT token to Codex. An API key is metered
17
+ * capacity rather than a seat, so it stays eligible for whichever app is asking.
18
+ */
19
+ export function canServeProvider(account, provider) {
20
+ return providerOf(account) === provider || !isSubscriptionAccount(account);
21
+ }
22
+
23
+ /**
24
+ * Warn about a route that no inbound Claude Code request can be served by.
25
+ *
26
+ * The failure this exists for, observed live on 2026-09-15: a `gpt-*` route lost
27
+ * the one account that served its inbound leg — the local translating sidecar —
28
+ * leaving only the ChatGPT subscriptions its back leg draws on. Those are Codex
29
+ * accounts, so they serve `/backend-api/codex/*` and nothing else. Every GPT
30
+ * request died instantly while `teamclaude status` showed two healthy accounts
31
+ * sitting on the route, and nothing anywhere said why.
32
+ *
33
+ * Only routes with an explicit `accounts` list are checked. An empty list means
34
+ * "the whole fleet", which cannot have this problem — and the explicit list is
35
+ * where the trap lives, because it is edited by hand and silently load-bearing.
36
+ */
37
+ export function routeReachabilityWarnings(routes = [], accounts = []) {
38
+ const warnings = [];
39
+ for (const route of routes || []) {
40
+ const listed = route?.accounts;
41
+ if (!Array.isArray(listed) || listed.length === 0) continue;
42
+ const members = (accounts || []).filter(a =>
43
+ listed.includes(a?.name) || (a?.index != null && listed.includes(String(a.index))));
44
+ // No member resolves at all: a different fault (a name that names nothing),
45
+ // and reporting it as "cannot serve" would point at the wrong repair.
46
+ if (members.length === 0) continue;
47
+ if (members.some(a => canServeProvider(a, DEFAULT_PROVIDER))) continue;
48
+
49
+ const names = members.map(a => a.name).join(', ');
50
+ const providers = [...new Set(members.map(a => providerOf(a)))].join('/');
51
+ const globs = (route.match || []).join(', ');
52
+ warnings.push(
53
+ `[TeamClaude] Route "${route.name}"${globs ? ` (${globs})` : ''} has no account that can serve `
54
+ + `/v1/messages — every account it lists (${names}) is a ${providers} subscription, which serves `
55
+ + 'only its own path. Claude Code requests matching this route will find no account, while '
56
+ + '`teamclaude status` shows the route healthy. Add back the account that serves the inbound '
57
+ + 'leg (for a sidecar setup, the local one).');
58
+ }
59
+ return warnings;
60
+ }
package/src/server.js CHANGED
@@ -12,7 +12,7 @@ import { parseRequestModel, parseAdvisorModel } from './account-manager.js';
12
12
  import { TopLevelFieldFinder, modelGlobMatches } from './model.js';
13
13
  import { BodyWriter, truncationNote } from './request-log.js';
14
14
  import { upstreamFetch, upstreamPoolStatus } from './upstream-fetch.js';
15
- import { applyAuthHeaders, upstreamFor, rewritesBody, providerForPath, providerOf, isSubscriptionAccount, DEFAULT_PROVIDER } from './provider.js';
15
+ import { applyAuthHeaders, upstreamFor, rewritesBody, providerForPath, providerOf, isSubscriptionAccount, holdsConnection, DEFAULT_PROVIDER } from './provider.js';
16
16
  import { tunnelTls } from './sx.js';
17
17
  import { createEgressGuard } from './egress-guard.js';
18
18
  import { safeLine } from './safe-text.js';
@@ -2074,7 +2074,11 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
2074
2074
  // recovers or the budget (holdSeconds) runs out. Claude Code waits for
2075
2075
  // the first response byte, so this is transparent to the client as long
2076
2076
  // as API_TIMEOUT_MS on the Claude Code side is large enough.
2077
- if (ctx.holdBudgetMs > 0) {
2077
+ //
2078
+ // Which is exactly the assumption `holdsConnection` exists to check. A
2079
+ // Codex caller gives up on the head long before the budget does, so for it
2080
+ // the hold is not transparent at all — it is the whole failure.
2081
+ if (ctx.holdBudgetMs > 0 && holdsConnection(ctx.provider)) {
2078
2082
  // Cap the per-poll sleep to 60s so a newly-available account (e.g. one
2079
2083
  // manually enabled or whose quota reset early) is picked up within a
2080
2084
  // minute instead of sleeping the full retryAfter (often 3600s).
@@ -2087,7 +2091,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
2087
2091
  }
2088
2092
 
2089
2093
  const exhaustedRetries = ctx.exhaustedRetries || 0;
2090
- if (exhaustedRetries < 1 && retryAfter <= INLINE_RETRY_AFTER_MAX_SECONDS) {
2094
+ if (exhaustedRetries < 1 && retryAfter <= INLINE_RETRY_AFTER_MAX_SECONDS && holdsConnection(ctx.provider)) {
2091
2095
  ctx.exhaustedRetries = exhaustedRetries + 1;
2092
2096
  console.log(`[TeamClaude] All accounts exhausted — waiting ${retryAfter}s before retry`);
2093
2097
  await waitForRetry(retryAfter * 1000, ctx.signal);
@@ -2415,17 +2419,19 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
2415
2419
  // Absorb short waits inline on the same account — the client never sees the
2416
2420
  // 429. Bounded by retryCount (maxRetries = account count) so a persistently
2417
2421
  // rate-limited account can't loop forever tying up the connection.
2418
- if (retryAfter <= RATE_LIMIT_ABSORB_MAX_SECONDS && retryCount < maxRetries) {
2422
+ if (retryAfter <= RATE_LIMIT_ABSORB_MAX_SECONDS && retryCount < maxRetries && holdsConnection(ctx.provider)) {
2419
2423
  console.log(`[TeamClaude] Rate-limit 429 on "${account.name}" — waiting ${retryAfter}s, retrying same account (no switch)`);
2420
2424
  await waitForRetry(retryAfter * 1000, ctx.signal);
2421
2425
  if (clientGone(res)) { ctx.abandoned = true; return; }
2422
2426
  return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, nextUseSx);
2423
2427
  }
2424
2428
 
2425
- // Longer retry-after (or retries exhausted): don't hold the connection and
2426
- // don't rotate — surface the 429 with retry-after so the client backs off.
2427
- // The pause above keeps other requests off this account meanwhile.
2428
- console.log(`[TeamClaude] Rate-limit 429 on "${account.name}" — retry-after ${retryAfter}s over inline cap; returning 429 to client (no switch)`);
2429
+ // Longer retry-after, retries exhausted, or a caller that will not wait
2430
+ // for us (see holdsConnection): don't hold the connection and don't
2431
+ // rotate — surface the 429 with retry-after so the client backs off. The
2432
+ // pause above keeps other requests off this account meanwhile.
2433
+ const why = holdsConnection(ctx.provider) ? `retry-after ${retryAfter}s over inline cap` : `${ctx.provider} caller does not wait`;
2434
+ console.log(`[TeamClaude] Rate-limit 429 on "${account.name}" — ${why}; returning 429 to client (no switch)`);
2429
2435
  ctx.status = 429;
2430
2436
  if (!res.headersSent && !clientGone(res)) {
2431
2437
  res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': String(retryAfter) });
@@ -217,7 +217,13 @@ function routingLines(routes, blocked, paint) {
217
217
  // which hop each account serves instead of reading as one flat pool.
218
218
  const routeProvider = route.provider || 'anthropic';
219
219
  const accountText = (a) => {
220
- const tag = a.provider && a.provider !== routeProvider ? `:${nameText(a.provider)}` : '';
220
+ const foreign = a.provider && a.provider !== routeProvider;
221
+ // Unless the name already leads with it. `login --codex` mints
222
+ // `codex:someone@example.com`, so tagging that again reads
223
+ // `codex:someone@example.com:codex` — the same word twice, once as the
224
+ // thing's name and once as a fact about it.
225
+ const saysSoItself = foreign && a.name.startsWith(`${a.provider}:`);
226
+ const tag = foreign && !saysSoItself ? `:${nameText(a.provider)}` : '';
221
227
  return nameText(a.name) + tag;
222
228
  };
223
229
  const accounts = routeBlocked
package/src/tui.js CHANGED
@@ -494,6 +494,18 @@ export class TUI {
494
494
  start() {
495
495
  this.running = true;
496
496
  this._openActivityLog();
497
+ // Node puts a TTY stdout in BLOCKING mode, so every paint is a synchronous
498
+ // write(2) that returns only when the terminal has drained the pty. That
499
+ // makes the proxy's event loop hostage to its own display: when the
500
+ // terminal emulator pauses — an Electron pane busy elsewhere, a window
501
+ // occluded, the machine dozing — the write sits in the kernel and nothing
502
+ // else runs: no upstream bytes relayed, no request completed, no log line.
503
+ // Measured live: stalls of 5-29s, the main thread in write() under
504
+ // StreamBase::WriteString, with sessions "waiting for API response" and
505
+ // nothing to see anywhere because the thing that would show it is the
506
+ // thing blocked. Non-blocking here, and the paint below drops a frame
507
+ // when the terminal is behind instead of waiting for it.
508
+ this._setStdoutBlocking(false);
497
509
  process.stdout.write(`${ESC}?1049h${ESC}?25l`);
498
510
  process.stdin.setRawMode(true);
499
511
  process.stdin.resume();
@@ -550,6 +562,12 @@ export class TUI {
550
562
  if (this._activityStream) { this._activityStream.end(); this._activityStream = null; }
551
563
  process.stdin.removeListener('data', this._dataHandler);
552
564
  process.stdout.removeListener('resize', this._resizeHandler);
565
+ if (this._drainHandler) { process.stdout.removeListener('drain', this._drainHandler); this._drainHandler = null; }
566
+ // Blocking again for the exit sequence: a non-blocking write can still be
567
+ // queued when the process exits, and a terminal left on the alternate
568
+ // screen with no cursor is the one state an operator cannot recover
569
+ // without knowing the escape by heart.
570
+ this._setStdoutBlocking(true);
553
571
  process.stdout.write(`${ESC}?25h${ESC}?1049l`);
554
572
  try { process.stdin.setRawMode(false); } catch {}
555
573
  process.stdin.pause();
@@ -1361,11 +1379,35 @@ export class TUI {
1361
1379
  _paint(buf, force) {
1362
1380
  const stale = Date.now() - (this._lastPaintAt || 0) >= FORCE_REPAINT_MS;
1363
1381
  if (!force && !stale && buf === this._lastFrame) return;
1382
+ // The terminal has not taken the previous frame yet. Painting anyway would
1383
+ // only queue another full screen behind it — the operator sees the newest
1384
+ // frame either way, so the one in between is worth nothing. Drop it, and
1385
+ // paint what is current once the terminal catches up.
1386
+ if (process.stdout.writableNeedDrain) {
1387
+ this._pendingPaint = true;
1388
+ if (!this._drainHandler) {
1389
+ this._drainHandler = () => {
1390
+ this._drainHandler = null;
1391
+ if (this._pendingPaint && this.running) { this._pendingPaint = false; this.render({ force: true }); }
1392
+ };
1393
+ process.stdout.once('drain', this._drainHandler);
1394
+ }
1395
+ return;
1396
+ }
1397
+ this._pendingPaint = false;
1364
1398
  this._lastFrame = buf;
1365
1399
  this._lastPaintAt = Date.now();
1366
1400
  process.stdout.write(buf);
1367
1401
  }
1368
1402
 
1403
+ /** Flip stdout between blocking and non-blocking. A handle without the
1404
+ * method (a pipe in tests, a file) needs neither, and a failure to flip is
1405
+ * worth no more than the old behaviour it leaves in place. */
1406
+ _setStdoutBlocking(blocking) {
1407
+ // `_handle` is Node-internal and untyped; the optional chain is the guard.
1408
+ try { /** @type {any} */ (process.stdout)._handle?.setBlocking?.(blocking); } catch {}
1409
+ }
1410
+
1369
1411
  _render(force = false) {
1370
1412
  // Reset the display the instant a quota window (e.g. 5-hour session) expires,
1371
1413
  // instead of waiting for the next request to clear it.