@rikcodes/teamclaude 1.1.20-rik.2 → 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/README.md +60 -2
- package/package.json +1 -1
- package/src/account-manager.js +40 -15
- package/src/identity.js +45 -0
- package/src/index.js +32 -3
- package/src/provider.js +37 -0
- package/src/route-warnings.js +60 -0
- package/src/server.js +22 -14
- package/src/status-renderer.js +16 -1
- package/src/tui-remote.js +15 -1
- package/src/tui.js +52 -12
package/README.md
CHANGED
|
@@ -163,9 +163,67 @@ Each request is routed by the model name in its body, so one session can freely
|
|
|
163
163
|
2. Add a `customModels` row. Codex publishes the window for each model as `context_window` in `~/.codex/models_cache.json`; copy it to `contextTokens`.
|
|
164
164
|
3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary.
|
|
165
165
|
|
|
166
|
-
Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback
|
|
166
|
+
Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
The sidecar appears under the account table as a `⚙` line rather than a row because it holds no subscription, is the only account its route can use, and never rotates. The line also shows its supervised process state (`up pid 98018`, or `down (code 1) 3 restarts`).
|
|
169
|
+
|
|
170
|
+
#### Several ChatGPT accounts
|
|
171
|
+
|
|
172
|
+
> **Read the [terms of service](docs/openai.md#terms-of-service) before setting this up.** OpenAI's Terms of Use prohibit rotating ChatGPT subscriptions past a spent window, and account suspension is a plausible consequence. This is a sharper trade-off than pooling Claude subscriptions because the first-party client lets you switch Claude subscriptions by hand.
|
|
173
|
+
|
|
174
|
+
One sidecar holds one ChatGPT login, so GPT requests do not rotate and its quota belongs to a login that TeamClaude does not own. Point the sidecar's **back leg** at TeamClaude so native Codex accounts serve it instead:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
Claude Code ──▶ TC /v1/messages (gpt-*) ──▶ sidecar account ──▶ sidecar translates
|
|
178
|
+
──▶ TC /backend-api/codex/responses ──▶ ChatGPT account pool ──▶ chatgpt.com
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Each hop is classified by its path, and the subscription partition keeps the pools apart: on the way in only the sidecar account is eligible, on the way back only the ChatGPT accounts. One route lists both.
|
|
182
|
+
|
|
183
|
+
**1. Add the accounts** — run `teamclaude login --codex` once for each one. A Codex login takes its email as its name. Your Anthropic account probably uses the same name, so the Codex name gets a prefix to keep it unambiguous:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
$ teamclaude login --codex
|
|
187
|
+
Named "codex:you@example.com" — "you@example.com" is already an account on another provider.
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**2. Redirect the sidecar** and stub its own login, so TeamClaude supplies the credential instead:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{ "name": "codex",
|
|
194
|
+
"command": ["claude-code-proxy", "serve", "--no-monitor", "--port", "18765"],
|
|
195
|
+
"env": {
|
|
196
|
+
"CCP_CODEX_BASE_URL": "http://127.0.0.1:3456/backend-api/codex/responses",
|
|
197
|
+
"CCP_CODEX_TRANSPORT": "http"
|
|
198
|
+
} }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
cd ~/.config/claude-code-proxy/codex
|
|
203
|
+
cp auth.json auth.json.bak # the real login — keep it
|
|
204
|
+
echo '{ "access": "delegated-to-teamclaude", "refresh": "", "expires": 4102444800000 }' > auth.json
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The sidecar refuses to start with an empty store but never refreshes a far-future token, and TeamClaude replaces both the bearer and the account header on the way out. Leave `accountId` unset so none of the sidecar's own identity can leak.
|
|
208
|
+
|
|
209
|
+
**3. Put them all on the `gpt-*` route**, sidecar included, and give each account a `headersTimeoutMs` — the 120s fleet default is shorter than a long reasoning turn:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{ "name": "codex", "match": ["gpt-*"],
|
|
213
|
+
"accounts": ["codex", "codex:you@example.com", "codex:you@work.example"] }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**4. Restart the server.** A `sidecars[].env` change is read once at startup, so a reload is not enough.
|
|
217
|
+
|
|
218
|
+
Three details matter:
|
|
219
|
+
|
|
220
|
+
- **Leave the sidecar account on the route.** It can look removable because it is not a subscription or an account row, but it is the routing target for the way *in*. Without it, every `gpt-*` request fails to find an account while `teamclaude status` shows two healthy ChatGPT accounts on the route.
|
|
221
|
+
- **`CCP_CODEX_TRANSPORT=http` is required.** A WebSocket upgrade is relayed with the caller's own headers and draws no account, so the WebSocket transport cannot be pooled.
|
|
222
|
+
- **Do not reuse a name across providers.** Routes address accounts by name, so a shared name admits both — including the Claude account that cannot serve `gpt-*`, which outranks the sidecar on priority and wins. TeamClaude warns at startup when it sees one.
|
|
223
|
+
|
|
224
|
+
Two things differ from the single-account setup: each turn appears **twice** in the activity list, once per hop, and tokens are booked against the sidecar account, so a ChatGPT account reads `N req · 0 tok`. Its quota bars are unaffected because they come from the `x-codex-*` headers on the second hop, where the subscription is.
|
|
225
|
+
|
|
226
|
+
Full details, including what happens to quota on each hop: [Several ChatGPT accounts behind one sidecar](docs/openai.md#several-chatgpt-accounts-behind-one-sidecar).
|
|
169
227
|
|
|
170
228
|
### Burn-rate projection
|
|
171
229
|
|
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/account-manager.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired, formatMoney } from './oauth.js';
|
|
2
|
-
import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount } from './provider.js';
|
|
2
|
+
import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount, isLocalUpstream } from './provider.js';
|
|
3
3
|
import { refreshCodexToken } from './codex-auth.js';
|
|
4
4
|
import { parseCodexQuota, parseCodexPlanType } from './codex-quota.js';
|
|
5
5
|
import { sameIdentity } from './identity.js';
|
|
@@ -2699,14 +2699,27 @@ export class AccountManager {
|
|
|
2699
2699
|
}
|
|
2700
2700
|
|
|
2701
2701
|
/** Accounts a configured route can use (all accounts when it lists none), each
|
|
2702
|
-
* with a live eligibility flag for a representative model of the route
|
|
2702
|
+
* with a live eligibility flag for a representative model of the route and the
|
|
2703
|
+
* provider that will serve it.
|
|
2704
|
+
*
|
|
2705
|
+
* A route that NAMES its accounts is shown in full, whatever provider each one
|
|
2706
|
+
* belongs to. One route legitimately spans two: a translating sidecar reached
|
|
2707
|
+
* on the Anthropic wire, plus the subscription pool its own back leg re-enters
|
|
2708
|
+
* on the provider's path. Both hops are that route's traffic. Filtering the
|
|
2709
|
+
* view by a single provider hid the second set completely — the route looked
|
|
2710
|
+
* like it listed one account, and a newly added subscription that nobody had
|
|
2711
|
+
* added to the list was invisible rather than merely idle, which is the exact
|
|
2712
|
+
* shape of the diagnosis this view exists to prevent.
|
|
2713
|
+
*
|
|
2714
|
+
* A route that lists NOBODY is different: it constrains models, not accounts,
|
|
2715
|
+
* so only the asking provider's own pool can serve it and the partition still
|
|
2716
|
+
* applies. */
|
|
2703
2717
|
_routeAccountsView(route, provider = DEFAULT_PROVIDER) {
|
|
2704
2718
|
const sample = sampleModelFor(route);
|
|
2705
|
-
const
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
return
|
|
2709
|
-
.map(a => ({ name: a.name, eligible: this._isAvailable(a, sample) }));
|
|
2719
|
+
const listed = route.accounts.length
|
|
2720
|
+
? this.accounts.filter(a => route.accounts.includes(a.name) || route.accounts.includes(String(a.index)))
|
|
2721
|
+
: this.accounts.filter(a => !this._excludeOtherProviders(null, provider)?.has(a.index));
|
|
2722
|
+
return listed.map(a => ({ name: a.name, provider: providerOf(a), eligible: this._isAvailable(a, sample) }));
|
|
2710
2723
|
}
|
|
2711
2724
|
|
|
2712
2725
|
/** A representative model id for a route name (configured or auto fable/sonnet),
|
|
@@ -3384,14 +3397,26 @@ export class AccountManager {
|
|
|
3384
3397
|
// display, projection and switch-threshold logic apply unchanged. A window
|
|
3385
3398
|
// with no length is a bucket the plan does not have, not one at 0% used.
|
|
3386
3399
|
// used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3400
|
+
//
|
|
3401
|
+
// Unless this account is a CONDUIT: a local proxy whose own back leg draws
|
|
3402
|
+
// on the Codex accounts in this same fleet. Then the numbers it forwards
|
|
3403
|
+
// belong to whichever of them served, and filing them here makes the
|
|
3404
|
+
// conduit's bars a copy of the last one to answer. That is not merely a
|
|
3405
|
+
// wrong readout — the conduit is the only account its route can use on the
|
|
3406
|
+
// way in, so borrowing a spent account's number takes it below the switch
|
|
3407
|
+
// threshold and every request fails, while a sibling sits at 0%.
|
|
3408
|
+
// A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
|
|
3409
|
+
// its own login, the forwarded numbers are its own, and they still apply.
|
|
3410
|
+
if (!(isLocalUpstream(account) && this.accounts.some(a => providerOf(a) === 'codex'))) {
|
|
3411
|
+
for (const window of ['primary', 'secondary']) {
|
|
3412
|
+
const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
|
|
3413
|
+
const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
|
|
3414
|
+
if (isNaN(used) || !(minutes > 0)) continue;
|
|
3415
|
+
const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
|
|
3416
|
+
const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
|
|
3417
|
+
account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
|
|
3418
|
+
if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
|
|
3419
|
+
}
|
|
3395
3420
|
}
|
|
3396
3421
|
|
|
3397
3422
|
// Standard rate limits (API key accounts)
|
package/src/identity.js
CHANGED
|
@@ -188,3 +188,48 @@ export function oauthIdentityFields(profile) {
|
|
|
188
188
|
.map(key => [key, profile[key]])
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Names held by more than one account, with the providers that hold each.
|
|
194
|
+
*
|
|
195
|
+
* `name` is this proxy's ADDRESSING key — routes name accounts by it
|
|
196
|
+
* (`_routeAllows`), so do `TC_ACCT`, the `/tc-acct/` pin, `teamclaude disable`
|
|
197
|
+
* and the TUI pickers. Identity, by contrast, is deliberately provider-aware:
|
|
198
|
+
* `sameIdentity` treats two providers as separate plans, so signing the same
|
|
199
|
+
* person's email into Anthropic and into Codex correctly yields two accounts.
|
|
200
|
+
*
|
|
201
|
+
* Those two rules meet badly. Two distinct accounts sharing a name make every
|
|
202
|
+
* name-addressed lookup ambiguous, and the lookups do not agree with each other
|
|
203
|
+
* about which one they mean: `_routeAllows` admits BOTH, while
|
|
204
|
+
* `resolveAccountPin` takes the first. Listing a shared name in a route
|
|
205
|
+
* therefore quietly admits an account that cannot serve the request — and when
|
|
206
|
+
* that account outranks the intended one on priority, it wins.
|
|
207
|
+
*
|
|
208
|
+
* Detection only. Nothing here refuses a config: an operator who wants two rows
|
|
209
|
+
* called the same thing may have a reason, and a proxy that will not start is
|
|
210
|
+
* worse than one that says what is wrong.
|
|
211
|
+
*
|
|
212
|
+
* @param {Array<{name?: string}>} accounts
|
|
213
|
+
* @returns {Array<{name: string, providers: string[]}>}
|
|
214
|
+
*/
|
|
215
|
+
export function duplicateAccountNames(accounts = []) {
|
|
216
|
+
const byName = new Map();
|
|
217
|
+
for (const a of accounts) {
|
|
218
|
+
const name = a?.name;
|
|
219
|
+
if (typeof name !== 'string' || !name) continue;
|
|
220
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
221
|
+
byName.get(name).push(providerOf(a));
|
|
222
|
+
}
|
|
223
|
+
return [...byName.entries()]
|
|
224
|
+
.filter(([, providers]) => providers.length > 1)
|
|
225
|
+
.map(([name, providers]) => ({ name, providers }));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The duplicate-name warning lines for `accounts`, or [] when there are none. */
|
|
229
|
+
export function duplicateNameWarnings(accounts = []) {
|
|
230
|
+
return duplicateAccountNames(accounts).map(({ name, providers }) =>
|
|
231
|
+
`[TeamClaude] Two accounts are named "${name}" (${providers.join(', ')}). `
|
|
232
|
+
+ 'Routes, TC_ACCT and `teamclaude disable` address accounts by name, so this one is '
|
|
233
|
+
+ 'ambiguous: a route listing it admits both, including the one that cannot serve the '
|
|
234
|
+
+ 'request. Rename one (e.g. "codex:' + name + '").');
|
|
235
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
findUpsertTarget,
|
|
19
19
|
updateAccountEntry,
|
|
20
20
|
canUpsertOAuthAccount,
|
|
21
|
-
oauthIdentityFields,
|
|
22
|
-
} from './
|
|
21
|
+
oauthIdentityFields, duplicateNameWarnings } from './identity.js';
|
|
22
|
+
import { routeReachabilityWarnings } from './route-warnings.js';
|
|
23
23
|
import { resolveAccounts } from './resolve-accounts.js';
|
|
24
24
|
import { loginCodex } from './codex-auth.js';
|
|
25
25
|
import { syncAccountsFromDisk } from './sync-accounts.js';
|
|
@@ -286,6 +286,15 @@ async function serverCommand() {
|
|
|
286
286
|
console.error(`[TeamClaude] Bad adaptiveDistribution setting in ${getConfigPath()}: ${err.message}`);
|
|
287
287
|
process.exit(1);
|
|
288
288
|
}
|
|
289
|
+
// Name is the addressing key for routes, TC_ACCT and the CLI, while identity
|
|
290
|
+
// is provider-aware — so the same email on two providers is two accounts with
|
|
291
|
+
// one name, and every name lookup becomes ambiguous. Said once at startup and
|
|
292
|
+
// again after a reload, never fatal.
|
|
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);
|
|
289
298
|
const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, projection: config.projection, expiryRouting: config.expiryRouting, adaptive });
|
|
290
299
|
// Names the activity log's session column from Claude Code's own on-disk
|
|
291
300
|
// session titles. Built whether or not the TUI runs, so a reload has one
|
|
@@ -392,6 +401,7 @@ async function serverCommand() {
|
|
|
392
401
|
const diskConfig = await loadConfig();
|
|
393
402
|
if (!diskConfig) return 0;
|
|
394
403
|
const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
404
|
+
for (const line of duplicateNameWarnings(accountManager.accounts)) console.error(line);
|
|
395
405
|
// Pick up client-key edits (proxy.clientKeys is read live by both auth
|
|
396
406
|
// gates through the shared config object, so refreshing it here is all a
|
|
397
407
|
// key add/rotate/revoke needs — no restart).
|
|
@@ -408,6 +418,9 @@ async function serverCommand() {
|
|
|
408
418
|
// Pick up route table edits (teamclaude route …, TUI editor, or a hand edit).
|
|
409
419
|
config.routes = diskConfig.routes || [];
|
|
410
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);
|
|
411
424
|
// Pick up a distributeSessions change (hand edit or another writer) the same
|
|
412
425
|
// way routes, sx, probe and warmup are picked up below.
|
|
413
426
|
// Not coerced to a boolean: 'adaptive' is a third mode, and !! would flatten
|
|
@@ -497,6 +510,9 @@ async function serverCommand() {
|
|
|
497
510
|
if (config.routes != null) diskConfig.routes = config.routes;
|
|
498
511
|
}),
|
|
499
512
|
syncAccounts: reloadAccounts,
|
|
513
|
+
// Read through to the live supervisor rather than snapshotting: it
|
|
514
|
+
// respawns on its own schedule and the TUI redraws on a timer.
|
|
515
|
+
getSidecars: () => sidecar?.getStatus() || [],
|
|
500
516
|
// `p` key: on-demand fleet-wide quota refresh. The prober is constructed
|
|
501
517
|
// after the TUI, so this is a thunk over the closure variable.
|
|
502
518
|
probeQuota: () => prober?.probeAll(),
|
|
@@ -804,8 +820,21 @@ async function loginCodexCommand() {
|
|
|
804
820
|
// account would fail on its next restart. So the upsert runs against a fresh
|
|
805
821
|
// read of the file, and only this account's row is touched.
|
|
806
822
|
await atomicConfigUpdate(config => {
|
|
807
|
-
|
|
823
|
+
// A Codex login is named for its email, and the same person's Anthropic
|
|
824
|
+
// account is named for the same email — so the default collides by default.
|
|
825
|
+
// Name is the addressing key (routes, TC_ACCT, `disable`), and a route
|
|
826
|
+
// listing an ambiguous name admits BOTH accounts, including the one that
|
|
827
|
+
// cannot serve the request. Prefix rather than refuse: the operator asked
|
|
828
|
+
// for this login, and a name they did not choose is a smaller surprise than
|
|
829
|
+
// a failed command. An explicit --name is theirs and is left alone.
|
|
830
|
+
const preferred = creds.email
|
|
808
831
|
|| `codex-${config.accounts.filter(a => a.provider === 'codex').length + 1}`;
|
|
832
|
+
const takenByOther = (n) => config.accounts.some(a => a.name === n && a.provider !== 'codex');
|
|
833
|
+
const name = argValue('--name')
|
|
834
|
+
|| (takenByOther(preferred) ? `codex:${preferred}` : preferred);
|
|
835
|
+
if (!argValue('--name') && name !== preferred) {
|
|
836
|
+
console.log(`Named "${name}" — "${preferred}" is already an account on another provider.`);
|
|
837
|
+
}
|
|
809
838
|
|
|
810
839
|
const account = {
|
|
811
840
|
name,
|
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
|
|
|
@@ -169,6 +181,12 @@ export function applyAuthHeaders(headers, account) {
|
|
|
169
181
|
const provider = providerOf(account);
|
|
170
182
|
if (provider === 'codex') {
|
|
171
183
|
headers['authorization'] = `Bearer ${account.credential}`;
|
|
184
|
+
// Cleared before it is set, not merely overwritten. `authorization` is
|
|
185
|
+
// stripped from every inbound request, but this header is not — so a
|
|
186
|
+
// caller that sends one of its own (a translating sidecar does, from its
|
|
187
|
+
// own local login) would have it survive for an account that carries no
|
|
188
|
+
// accountId, pairing THIS account's token with THAT caller's account id.
|
|
189
|
+
delete headers['chatgpt-account-id'];
|
|
172
190
|
if (account.accountId) headers['chatgpt-account-id'] = account.accountId;
|
|
173
191
|
return;
|
|
174
192
|
}
|
|
@@ -195,6 +213,25 @@ export function upstreamFor(account, configuredUpstream) {
|
|
|
195
213
|
return configuredUpstream || PROVIDERS.anthropic.upstream;
|
|
196
214
|
}
|
|
197
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
|
+
|
|
198
235
|
/** Whether the Anthropic-only body rewrites apply to this account. */
|
|
199
236
|
export function rewritesBody(account) {
|
|
200
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';
|
|
@@ -801,13 +801,15 @@ const SESSION_ID_SHAPE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
|
801
801
|
/** The session id a request carries, or null when the header is absent or
|
|
802
802
|
* malformed — a malformed one is treated as no session, not rejected.
|
|
803
803
|
*
|
|
804
|
-
* Claude Code sends `x-claude-code-session-id`, the Codex CLI `session-id
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
804
|
+
* Claude Code sends `x-claude-code-session-id`, the Codex CLI `session-id`,
|
|
805
|
+
* and a translating sidecar re-emits the session it was given as `session_id`
|
|
806
|
+
* (the spelling the Codex backend itself uses). Reading only the first left
|
|
807
|
+
* every Codex request untagged, so `distributeSessions` had nothing to place
|
|
808
|
+
* and a Codex pool stayed on one account until the switch threshold. Order is
|
|
809
|
+
* most-specific first: the underscore form is the one a sidecar writes on our
|
|
810
|
+
* behalf, and `session-id` is generic enough for a proxy in front to set. */
|
|
809
811
|
export function clientSessionId(headers) {
|
|
810
|
-
const raw = headers['x-claude-code-session-id'] ?? headers['session-id'];
|
|
812
|
+
const raw = headers['x-claude-code-session-id'] ?? headers['session-id'] ?? headers['session_id'];
|
|
811
813
|
return typeof raw === 'string' && SESSION_ID_SHAPE.test(raw) ? raw : null;
|
|
812
814
|
}
|
|
813
815
|
|
|
@@ -2072,7 +2074,11 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
2072
2074
|
// recovers or the budget (holdSeconds) runs out. Claude Code waits for
|
|
2073
2075
|
// the first response byte, so this is transparent to the client as long
|
|
2074
2076
|
// as API_TIMEOUT_MS on the Claude Code side is large enough.
|
|
2075
|
-
|
|
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)) {
|
|
2076
2082
|
// Cap the per-poll sleep to 60s so a newly-available account (e.g. one
|
|
2077
2083
|
// manually enabled or whose quota reset early) is picked up within a
|
|
2078
2084
|
// minute instead of sleeping the full retryAfter (often 3600s).
|
|
@@ -2085,7 +2091,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
2085
2091
|
}
|
|
2086
2092
|
|
|
2087
2093
|
const exhaustedRetries = ctx.exhaustedRetries || 0;
|
|
2088
|
-
if (exhaustedRetries < 1 && retryAfter <= INLINE_RETRY_AFTER_MAX_SECONDS) {
|
|
2094
|
+
if (exhaustedRetries < 1 && retryAfter <= INLINE_RETRY_AFTER_MAX_SECONDS && holdsConnection(ctx.provider)) {
|
|
2089
2095
|
ctx.exhaustedRetries = exhaustedRetries + 1;
|
|
2090
2096
|
console.log(`[TeamClaude] All accounts exhausted — waiting ${retryAfter}s before retry`);
|
|
2091
2097
|
await waitForRetry(retryAfter * 1000, ctx.signal);
|
|
@@ -2413,17 +2419,19 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
2413
2419
|
// Absorb short waits inline on the same account — the client never sees the
|
|
2414
2420
|
// 429. Bounded by retryCount (maxRetries = account count) so a persistently
|
|
2415
2421
|
// rate-limited account can't loop forever tying up the connection.
|
|
2416
|
-
if (retryAfter <= RATE_LIMIT_ABSORB_MAX_SECONDS && retryCount < maxRetries) {
|
|
2422
|
+
if (retryAfter <= RATE_LIMIT_ABSORB_MAX_SECONDS && retryCount < maxRetries && holdsConnection(ctx.provider)) {
|
|
2417
2423
|
console.log(`[TeamClaude] Rate-limit 429 on "${account.name}" — waiting ${retryAfter}s, retrying same account (no switch)`);
|
|
2418
2424
|
await waitForRetry(retryAfter * 1000, ctx.signal);
|
|
2419
2425
|
if (clientGone(res)) { ctx.abandoned = true; return; }
|
|
2420
2426
|
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, nextUseSx);
|
|
2421
2427
|
}
|
|
2422
2428
|
|
|
2423
|
-
// Longer retry-after
|
|
2424
|
-
//
|
|
2425
|
-
//
|
|
2426
|
-
|
|
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)`);
|
|
2427
2435
|
ctx.status = 429;
|
|
2428
2436
|
if (!res.headersSent && !clientGone(res)) {
|
|
2429
2437
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': String(retryAfter) });
|
package/src/status-renderer.js
CHANGED
|
@@ -211,10 +211,25 @@ function routingLines(routes, blocked, paint) {
|
|
|
211
211
|
// say so, rather than listing eligible accounts it will never reach.
|
|
212
212
|
const routeBlocked = globs.length > 0
|
|
213
213
|
&& globs.every(g => blocked.some(p => modelGlobOverlaps(p, g)));
|
|
214
|
+
// A route may list accounts from two providers — a local translating sidecar
|
|
215
|
+
// on this route's own wire, plus the subscription pool its back leg reaches.
|
|
216
|
+
// Tag the ones that are not this route's own provider, so a mixed row says
|
|
217
|
+
// which hop each account serves instead of reading as one flat pool.
|
|
218
|
+
const routeProvider = route.provider || 'anthropic';
|
|
219
|
+
const accountText = (a) => {
|
|
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)}` : '';
|
|
227
|
+
return nameText(a.name) + tag;
|
|
228
|
+
};
|
|
214
229
|
const accounts = routeBlocked
|
|
215
230
|
? paint.red('blocked')
|
|
216
231
|
: (route.accounts || [])
|
|
217
|
-
.map(a => (a.eligible ? paint.green(
|
|
232
|
+
.map(a => (a.eligible ? paint.green(accountText(a)) : paint.red(accountText(a)))).join(' ') || paint.gray('(none)');
|
|
218
233
|
const tag = route.autocreated ? paint.dim(' (auto)') : route.bucket ? paint.dim(` [${nameText(route.bucket)}]`) : '';
|
|
219
234
|
const pin = route.pinned ? paint.dim(` [pinned: ${nameText(route.pinned)}]`) : '';
|
|
220
235
|
// padEnd on the raw text, color after, so ANSI codes don't throw off alignment.
|
package/src/tui-remote.js
CHANGED
|
@@ -264,7 +264,21 @@ export class RemoteAccountManager {
|
|
|
264
264
|
target: r?.target == null ? r?.target : text(r.target, NAME_MAX),
|
|
265
265
|
match: (Array.isArray(r?.match) ? r.match : []).map(g => text(g, 64)).filter(Boolean),
|
|
266
266
|
accounts: (Array.isArray(r?.accounts) ? r.accounts : [])
|
|
267
|
-
.map(a => ({
|
|
267
|
+
.map(a => ({
|
|
268
|
+
...a,
|
|
269
|
+
name: text(a?.name, NAME_MAX, '?'),
|
|
270
|
+
provider: a?.provider == null ? a?.provider : text(a.provider, 16),
|
|
271
|
+
eligible: !!a?.eligible,
|
|
272
|
+
})),
|
|
273
|
+
}));
|
|
274
|
+
// Conduit lines read this. Clamped like every other remote field: the
|
|
275
|
+
// payload is a server's word, not ours, and it reaches a rendered line.
|
|
276
|
+
this.sidecars = (Array.isArray(status?.sidecars) ? status.sidecars : []).map(sc => ({
|
|
277
|
+
name: text(sc?.name, NAME_MAX, '?'),
|
|
278
|
+
running: !!sc?.running,
|
|
279
|
+
pid: Number.isFinite(sc?.pid) ? sc.pid : null,
|
|
280
|
+
restarts: Number.isFinite(sc?.restarts) ? sc.restarts : 0,
|
|
281
|
+
lastExit: sc?.lastExit == null ? null : text(sc.lastExit, 48),
|
|
268
282
|
}));
|
|
269
283
|
this.status = status;
|
|
270
284
|
this.connected = true;
|
package/src/tui.js
CHANGED
|
@@ -405,6 +405,9 @@ function timestamp() {
|
|
|
405
405
|
|
|
406
406
|
export class TUI {
|
|
407
407
|
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null, probeQuota = null, activityLogPath = null,
|
|
408
|
+
// Supervised sidecar state for the conduit lines. A getter, not a snapshot:
|
|
409
|
+
// the supervisor respawns on its own schedule and the TUI redraws on a timer.
|
|
410
|
+
getSidecars = null,
|
|
408
411
|
// Attach mode: the accounts belong to a server in another process, reached
|
|
409
412
|
// over its control plane. Everything that would mutate local state is off,
|
|
410
413
|
// and a switch becomes a request (applySwitch) instead of an assignment.
|
|
@@ -429,6 +432,7 @@ export class TUI {
|
|
|
429
432
|
this.sx = sx; // sx.org proxy manager (may be null)
|
|
430
433
|
this.sxBalance = null; // last fetched sx.org balance, for the settings screen
|
|
431
434
|
this.probeQuota = probeQuota; // on-demand fleet-wide quota refresh (may be null)
|
|
435
|
+
this.getSidecars = getSidecars; // supervised sidecar state (may be null)
|
|
432
436
|
this.activityLogPath = activityLogPath;
|
|
433
437
|
this._readCredentials = readCredentials;
|
|
434
438
|
this._readProfile = readProfile;
|
|
@@ -1519,6 +1523,8 @@ export class TUI {
|
|
|
1519
1523
|
const b = budgets.get(categoryOf(this.am.accounts[i]));
|
|
1520
1524
|
lines.push(this._renderAcct(i, b.bw, b.showBoth, routes, genRoutes, familyTarget, b.showFamily, nameW));
|
|
1521
1525
|
}
|
|
1526
|
+
// Local backends sit under the seats, as a readout rather than rows.
|
|
1527
|
+
lines.push(...this._conduitLines());
|
|
1522
1528
|
}
|
|
1523
1529
|
|
|
1524
1530
|
// Routing is surfaced inline on each account row (see _renderAcct): a colored
|
|
@@ -1570,14 +1576,15 @@ export class TUI {
|
|
|
1570
1576
|
this._paint(buf, force);
|
|
1571
1577
|
}
|
|
1572
1578
|
|
|
1573
|
-
/** Manager indices
|
|
1574
|
-
* local process last, every other account left where it is.
|
|
1579
|
+
/** Manager indices of the accounts drawn as rows: the seats that rotate.
|
|
1575
1580
|
*
|
|
1576
|
-
* A local backend
|
|
1577
|
-
* infrastructure
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
* and
|
|
1581
|
+
* A local backend — a translating proxy in front of another vendor — is
|
|
1582
|
+
* infrastructure, not a seat. It holds no subscription (its token is a
|
|
1583
|
+
* placeholder), it is the only candidate its route has, so it never rotates,
|
|
1584
|
+
* and it has no quota of its own to show. Drawn among the accounts it was a
|
|
1585
|
+
* row of dashes and borrowed numbers in a table whose whole purpose is which
|
|
1586
|
+
* account is being spent. It gets its own line below instead — see
|
|
1587
|
+
* _conduitLines. Sorting it last was the first half of this thought.
|
|
1581
1588
|
*
|
|
1582
1589
|
* Display only. `selIdx`, `currentIndex`, session pins and route entries all
|
|
1583
1590
|
* stay manager indices, so nothing about selection or routing moves with the
|
|
@@ -1586,11 +1593,44 @@ export class TUI {
|
|
|
1586
1593
|
_displayOrder() {
|
|
1587
1594
|
return this.am.accounts
|
|
1588
1595
|
.map((_, i) => i)
|
|
1589
|
-
.
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1596
|
+
.filter(i => !isLocalUpstream(this.am.accounts[i]));
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** Manager indices of the local backends, in config order. */
|
|
1600
|
+
_conduitOrder() {
|
|
1601
|
+
return this.am.accounts.map((_, i) => i).filter(i => isLocalUpstream(this.am.accounts[i]));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** One line per local backend: what it is, where it sends, and whether it can
|
|
1605
|
+
* serve. Its supervised process's state is folded in when this TUI has it
|
|
1606
|
+
* (the server passes a getter; the remote TUI reads the status payload), so
|
|
1607
|
+
* a crash-looping sidecar says so here rather than only in `status --json`.
|
|
1608
|
+
*
|
|
1609
|
+
* Deliberately terse. There is nothing to choose between, so this is a
|
|
1610
|
+
* readout, not a row: the operator needs "is it up" and nothing else. */
|
|
1611
|
+
_conduitLines() {
|
|
1612
|
+
const sidecars = this._sidecars();
|
|
1613
|
+
return this._conduitOrder().map(i => {
|
|
1614
|
+
const a = this.am.accounts[i];
|
|
1615
|
+
let host = a.upstream;
|
|
1616
|
+
try { host = new URL(a.upstream).host; } catch { /* keep the raw string */ }
|
|
1617
|
+
// Matched by name: a sidecars[] entry and the account that routes to it
|
|
1618
|
+
// are named by the same operator, and nothing else pairs them.
|
|
1619
|
+
const proc = sidecars.find(sc => sc.name === a.name) || null;
|
|
1620
|
+
const state = a.disabled ? red('disabled')
|
|
1621
|
+
: a.rateLimitedUntil > Date.now() ? yellow('throttled')
|
|
1622
|
+
: proc && !proc.running ? red(`down (${proc.lastExit || 'restarting'})`)
|
|
1623
|
+
: proc ? green('up') : green('ok');
|
|
1624
|
+
const pid = proc?.running ? dim(` pid ${proc.pid}`) : '';
|
|
1625
|
+
const restarts = proc?.restarts ? yellow(` ${proc.restarts} restarts`) : '';
|
|
1626
|
+
return ` ${dim('⚙')} ${a.name} ${dim('→')} ${dim(host)} ${state}${pid}${restarts}`;
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Supervised sidecar state, or [] when this TUI has no view of it. */
|
|
1631
|
+
_sidecars() {
|
|
1632
|
+
const list = this.getSidecars ? this.getSidecars() : this.am.sidecars;
|
|
1633
|
+
return Array.isArray(list) ? list : [];
|
|
1594
1634
|
}
|
|
1595
1635
|
|
|
1596
1636
|
_renderAcct(idx, bw, showBoth, routes = this.am.getRoutes(), genRoutes = routes.filter(r => routeFamily(r) === null), familyTarget = {}, showFamily = true, nameW = NAME_MIN) {
|