@rikcodes/teamclaude 1.1.20-rik.3 → 1.1.20-rik.4
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 +1 -1
- package/src/index.js +8 -0
- package/src/provider.js +31 -0
- package/src/route-warnings.js +60 -0
- package/src/server.js +14 -8
- package/src/status-renderer.js +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.20-rik.
|
|
3
|
+
"version": "1.1.20-rik.4",
|
|
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
|
-
|
|
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
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
|
|
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) });
|
package/src/status-renderer.js
CHANGED
|
@@ -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
|
|
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
|