@rikcodes/teamclaude 1.1.20-rik.1 → 1.1.20-rik.3
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 +61 -3
- package/package.json +1 -1
- package/src/account-manager.js +40 -15
- package/src/dashboard.js +13 -4
- package/src/identity.js +45 -0
- package/src/index.js +30 -6
- package/src/provider.js +6 -0
- package/src/server.js +8 -6
- package/src/status-renderer.js +10 -1
- package/src/tui-remote.js +15 -1
- package/src/tui.js +59 -14
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ At launch, `teamclaude run` — and the `claude` alias, which passes through `ru
|
|
|
150
150
|
| Row field | Where it ends up |
|
|
151
151
|
| --- | --- |
|
|
152
152
|
| `model`, `label`, `description` | A `/model` picker row under the **real** model id (`--settings`), so `/model gpt-5.6-sol` works picked or typed |
|
|
153
|
-
| `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent |
|
|
153
|
+
| `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent. Set `"customModelAgents": false` to skip these if you define your own agents in `~/.claude/agents/` |
|
|
154
154
|
| `contextTokens` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, set to the largest value across rows, so Claude Code compacts at the real window instead of assuming 200k |
|
|
155
155
|
|
|
156
156
|
For tools that spawn `claude` themselves, `teamclaude env` can set only environment variables. It carries the window and `ANTHROPIC_CUSTOM_MODEL_OPTION` for the **first** row. For GPT subagents under `env`, create `~/.claude/agents/<name>.md` with `model: gpt-5.6-terra` in its frontmatter.
|
|
@@ -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.3",
|
|
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/dashboard.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// /teamclaude/status (same origin) with the proxy key and re-renders every few
|
|
6
6
|
// seconds. That split is what lets the asset be served without the key (a
|
|
7
7
|
// browser address bar cannot send x-api-key) while every byte of actual status
|
|
8
|
-
// stays behind the existing gate. The key is asked for
|
|
9
|
-
//
|
|
8
|
+
// stays behind the existing gate. The key is asked for only when the server
|
|
9
|
+
// refuses the page without one (401/403; loopback browsers are exempt), and is
|
|
10
|
+
// kept in localStorage; a later refusal (wrong or rotated key) asks again.
|
|
10
11
|
//
|
|
11
12
|
// Self-contained on purpose: no external scripts, styles, or fonts, so the
|
|
12
13
|
// page works on air-gapped deployments and adds no third-party surface. All
|
|
@@ -894,7 +895,10 @@ ${SHARED_HELPERS}
|
|
|
894
895
|
function poll() {
|
|
895
896
|
fetch('/teamclaude/status', { headers: { 'x-api-key': localStorage.getItem(KEY) || '' } })
|
|
896
897
|
.then(function (res) {
|
|
897
|
-
|
|
898
|
+
// 403 is the loopback exemption refusing a key-less request (a Host
|
|
899
|
+
// that does not name this machine, e.g. behind a local reverse proxy
|
|
900
|
+
// that adds no forwarding headers). A valid key clears that gate too.
|
|
901
|
+
if (res.status === 401 || res.status === 403) { localStorage.removeItem(KEY); showKeybox(); return null; }
|
|
898
902
|
if (!res.ok) throw new Error('status ' + res.status);
|
|
899
903
|
return res.json();
|
|
900
904
|
})
|
|
@@ -909,6 +913,9 @@ ${SHARED_HELPERS}
|
|
|
909
913
|
var err = document.getElementById('err');
|
|
910
914
|
err.style.display = 'block';
|
|
911
915
|
err.textContent = 'Cannot reach the proxy: ' + e.message;
|
|
916
|
+
// The banner lives inside #app, which stays hidden until a first
|
|
917
|
+
// status lands; without this a first poll that fails is a blank page.
|
|
918
|
+
if (document.getElementById('keybox').style.display !== 'block') document.getElementById('app').style.display = '';
|
|
912
919
|
});
|
|
913
920
|
}
|
|
914
921
|
|
|
@@ -936,7 +943,9 @@ ${SHARED_HELPERS}
|
|
|
936
943
|
});
|
|
937
944
|
});
|
|
938
945
|
|
|
939
|
-
|
|
946
|
+
// Poll before asking: a loopback browser is key-exempt, so the prompt is
|
|
947
|
+
// shown only once the server refuses the request without a valid key.
|
|
948
|
+
start();
|
|
940
949
|
})();
|
|
941
950
|
</script>
|
|
942
951
|
</body>
|
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,7 @@ import {
|
|
|
18
18
|
findUpsertTarget,
|
|
19
19
|
updateAccountEntry,
|
|
20
20
|
canUpsertOAuthAccount,
|
|
21
|
-
oauthIdentityFields,
|
|
22
|
-
} from './identity.js';
|
|
21
|
+
oauthIdentityFields, duplicateNameWarnings } from './identity.js';
|
|
23
22
|
import { resolveAccounts } from './resolve-accounts.js';
|
|
24
23
|
import { loginCodex } from './codex-auth.js';
|
|
25
24
|
import { syncAccountsFromDisk } from './sync-accounts.js';
|
|
@@ -286,6 +285,11 @@ async function serverCommand() {
|
|
|
286
285
|
console.error(`[TeamClaude] Bad adaptiveDistribution setting in ${getConfigPath()}: ${err.message}`);
|
|
287
286
|
process.exit(1);
|
|
288
287
|
}
|
|
288
|
+
// Name is the addressing key for routes, TC_ACCT and the CLI, while identity
|
|
289
|
+
// is provider-aware — so the same email on two providers is two accounts with
|
|
290
|
+
// one name, and every name lookup becomes ambiguous. Said once at startup and
|
|
291
|
+
// again after a reload, never fatal.
|
|
292
|
+
for (const line of duplicateNameWarnings(accounts)) console.error(line);
|
|
289
293
|
const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, projection: config.projection, expiryRouting: config.expiryRouting, adaptive });
|
|
290
294
|
// Names the activity log's session column from Claude Code's own on-disk
|
|
291
295
|
// session titles. Built whether or not the TUI runs, so a reload has one
|
|
@@ -392,6 +396,7 @@ async function serverCommand() {
|
|
|
392
396
|
const diskConfig = await loadConfig();
|
|
393
397
|
if (!diskConfig) return 0;
|
|
394
398
|
const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
399
|
+
for (const line of duplicateNameWarnings(accountManager.accounts)) console.error(line);
|
|
395
400
|
// Pick up client-key edits (proxy.clientKeys is read live by both auth
|
|
396
401
|
// gates through the shared config object, so refreshing it here is all a
|
|
397
402
|
// key add/rotate/revoke needs — no restart).
|
|
@@ -472,7 +477,7 @@ async function serverCommand() {
|
|
|
472
477
|
|
|
473
478
|
if (useTUI) {
|
|
474
479
|
tui = new TUI({
|
|
475
|
-
accountManager, config, sx, activityLogPath, sessionTitles,
|
|
480
|
+
accountManager, config, sx, activityLogPath, sessionTitles, version: serverVersion,
|
|
476
481
|
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
477
482
|
diskConfig.accounts = mergeAccountsForSave(
|
|
478
483
|
config.accounts, accountManager.accounts, diskConfig.accounts, removedAccountIds(config),
|
|
@@ -497,6 +502,9 @@ async function serverCommand() {
|
|
|
497
502
|
if (config.routes != null) diskConfig.routes = config.routes;
|
|
498
503
|
}),
|
|
499
504
|
syncAccounts: reloadAccounts,
|
|
505
|
+
// Read through to the live supervisor rather than snapshotting: it
|
|
506
|
+
// respawns on its own schedule and the TUI redraws on a timer.
|
|
507
|
+
getSidecars: () => sidecar?.getStatus() || [],
|
|
500
508
|
// `p` key: on-demand fleet-wide quota refresh. The prober is constructed
|
|
501
509
|
// after the TUI, so this is a thunk over the closure variable.
|
|
502
510
|
probeQuota: () => prober?.probeAll(),
|
|
@@ -804,8 +812,21 @@ async function loginCodexCommand() {
|
|
|
804
812
|
// account would fail on its next restart. So the upsert runs against a fresh
|
|
805
813
|
// read of the file, and only this account's row is touched.
|
|
806
814
|
await atomicConfigUpdate(config => {
|
|
807
|
-
|
|
815
|
+
// A Codex login is named for its email, and the same person's Anthropic
|
|
816
|
+
// account is named for the same email — so the default collides by default.
|
|
817
|
+
// Name is the addressing key (routes, TC_ACCT, `disable`), and a route
|
|
818
|
+
// listing an ambiguous name admits BOTH accounts, including the one that
|
|
819
|
+
// cannot serve the request. Prefix rather than refuse: the operator asked
|
|
820
|
+
// for this login, and a name they did not choose is a smaller surprise than
|
|
821
|
+
// a failed command. An explicit --name is theirs and is left alone.
|
|
822
|
+
const preferred = creds.email
|
|
808
823
|
|| `codex-${config.accounts.filter(a => a.provider === 'codex').length + 1}`;
|
|
824
|
+
const takenByOther = (n) => config.accounts.some(a => a.name === n && a.provider !== 'codex');
|
|
825
|
+
const name = argValue('--name')
|
|
826
|
+
|| (takenByOther(preferred) ? `codex:${preferred}` : preferred);
|
|
827
|
+
if (!argValue('--name') && name !== preferred) {
|
|
828
|
+
console.log(`Named "${name}" — "${preferred}" is already an account on another provider.`);
|
|
829
|
+
}
|
|
809
830
|
|
|
810
831
|
const account = {
|
|
811
832
|
name,
|
|
@@ -1092,8 +1113,11 @@ async function runCommand() {
|
|
|
1092
1113
|
if (settings && !claudeArgs.includes('--settings')) claudeArgs.push('--settings', settings);
|
|
1093
1114
|
// Dispatchable subagents per custom model — the Agent tool's `model`
|
|
1094
1115
|
// parameter is an alias enum, so only a named agent definition can carry a
|
|
1095
|
-
// custom model id into a subagent.
|
|
1096
|
-
|
|
1116
|
+
// custom model id into a subagent. `customModelAgents: false` skips them for
|
|
1117
|
+
// operators with their own ~/.claude/agents definitions: the plain agents
|
|
1118
|
+
// invite an effort-less dispatch, and a file-based agent's `model:` reaches
|
|
1119
|
+
// the proxy without them.
|
|
1120
|
+
const agents = config.customModelAgents === false ? null : buildCustomModelAgents(config.customModels);
|
|
1097
1121
|
if (agents && !claudeArgs.includes('--agents')) claudeArgs.push('--agents', agents);
|
|
1098
1122
|
}
|
|
1099
1123
|
|
package/src/provider.js
CHANGED
|
@@ -169,6 +169,12 @@ export function applyAuthHeaders(headers, account) {
|
|
|
169
169
|
const provider = providerOf(account);
|
|
170
170
|
if (provider === 'codex') {
|
|
171
171
|
headers['authorization'] = `Bearer ${account.credential}`;
|
|
172
|
+
// Cleared before it is set, not merely overwritten. `authorization` is
|
|
173
|
+
// stripped from every inbound request, but this header is not — so a
|
|
174
|
+
// caller that sends one of its own (a translating sidecar does, from its
|
|
175
|
+
// own local login) would have it survive for an account that carries no
|
|
176
|
+
// accountId, pairing THIS account's token with THAT caller's account id.
|
|
177
|
+
delete headers['chatgpt-account-id'];
|
|
172
178
|
if (account.accountId) headers['chatgpt-account-id'] = account.accountId;
|
|
173
179
|
return;
|
|
174
180
|
}
|
package/src/server.js
CHANGED
|
@@ -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
|
|
package/src/status-renderer.js
CHANGED
|
@@ -211,10 +211,19 @@ 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 tag = a.provider && a.provider !== routeProvider ? `:${nameText(a.provider)}` : '';
|
|
221
|
+
return nameText(a.name) + tag;
|
|
222
|
+
};
|
|
214
223
|
const accounts = routeBlocked
|
|
215
224
|
? paint.red('blocked')
|
|
216
225
|
: (route.accounts || [])
|
|
217
|
-
.map(a => (a.eligible ? paint.green(
|
|
226
|
+
.map(a => (a.eligible ? paint.green(accountText(a)) : paint.red(accountText(a)))).join(' ') || paint.gray('(none)');
|
|
218
227
|
const tag = route.autocreated ? paint.dim(' (auto)') : route.bucket ? paint.dim(` [${nameText(route.bucket)}]`) : '';
|
|
219
228
|
const pin = route.pinned ? paint.dim(` [pinned: ${nameText(route.pinned)}]`) : '';
|
|
220
229
|
// 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.
|
|
@@ -414,7 +417,11 @@ export class TUI {
|
|
|
414
417
|
readCredentials = importCredentials, readProfile = fetchProfile,
|
|
415
418
|
// Names the activity column against the session id the client sent. Absent
|
|
416
419
|
// or disabled leaves every row showing the short id.
|
|
417
|
-
sessionTitles = null
|
|
420
|
+
sessionTitles = null,
|
|
421
|
+
// Shown faint beside the title. Null in attach mode, where the dashboard is
|
|
422
|
+
// built before the first poll and the server's version is not yet known,
|
|
423
|
+
// and in tests — both render the title alone rather than a stray `null`.
|
|
424
|
+
version = null }) {
|
|
418
425
|
this.am = accountManager;
|
|
419
426
|
this.remote = remote;
|
|
420
427
|
this.applySwitch = applySwitch;
|
|
@@ -425,11 +432,13 @@ export class TUI {
|
|
|
425
432
|
this.sx = sx; // sx.org proxy manager (may be null)
|
|
426
433
|
this.sxBalance = null; // last fetched sx.org balance, for the settings screen
|
|
427
434
|
this.probeQuota = probeQuota; // on-demand fleet-wide quota refresh (may be null)
|
|
435
|
+
this.getSidecars = getSidecars; // supervised sidecar state (may be null)
|
|
428
436
|
this.activityLogPath = activityLogPath;
|
|
429
437
|
this._readCredentials = readCredentials;
|
|
430
438
|
this._readProfile = readProfile;
|
|
431
439
|
this._activityStream = null;
|
|
432
440
|
this.sessionTitles = sessionTitles;
|
|
441
|
+
/** @type {string|null} */ this.version = version;
|
|
433
442
|
|
|
434
443
|
this.log = []; // completed activity entries
|
|
435
444
|
this.active = new Map(); // in-flight requests
|
|
@@ -1372,7 +1381,7 @@ export class TUI {
|
|
|
1372
1381
|
const lines = [];
|
|
1373
1382
|
|
|
1374
1383
|
// ── Header
|
|
1375
|
-
const left = bold('
|
|
1384
|
+
const left = bold(' RikClaude Harness') + (this.version ? dim(` ${this.version}`) : '');
|
|
1376
1385
|
const port = this.config.proxy?.port || 3456;
|
|
1377
1386
|
const sess = this.am.sessionStats();
|
|
1378
1387
|
const sessStr = (sess.active || sess.known)
|
|
@@ -1514,6 +1523,8 @@ export class TUI {
|
|
|
1514
1523
|
const b = budgets.get(categoryOf(this.am.accounts[i]));
|
|
1515
1524
|
lines.push(this._renderAcct(i, b.bw, b.showBoth, routes, genRoutes, familyTarget, b.showFamily, nameW));
|
|
1516
1525
|
}
|
|
1526
|
+
// Local backends sit under the seats, as a readout rather than rows.
|
|
1527
|
+
lines.push(...this._conduitLines());
|
|
1517
1528
|
}
|
|
1518
1529
|
|
|
1519
1530
|
// Routing is surfaced inline on each account row (see _renderAcct): a colored
|
|
@@ -1565,14 +1576,15 @@ export class TUI {
|
|
|
1565
1576
|
this._paint(buf, force);
|
|
1566
1577
|
}
|
|
1567
1578
|
|
|
1568
|
-
/** Manager indices
|
|
1569
|
-
* local process last, every other account left where it is.
|
|
1579
|
+
/** Manager indices of the accounts drawn as rows: the seats that rotate.
|
|
1570
1580
|
*
|
|
1571
|
-
* A local backend
|
|
1572
|
-
* infrastructure
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
* 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.
|
|
1576
1588
|
*
|
|
1577
1589
|
* Display only. `selIdx`, `currentIndex`, session pins and route entries all
|
|
1578
1590
|
* stay manager indices, so nothing about selection or routing moves with the
|
|
@@ -1581,11 +1593,44 @@ export class TUI {
|
|
|
1581
1593
|
_displayOrder() {
|
|
1582
1594
|
return this.am.accounts
|
|
1583
1595
|
.map((_, i) => i)
|
|
1584
|
-
.
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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 : [];
|
|
1589
1634
|
}
|
|
1590
1635
|
|
|
1591
1636
|
_renderAcct(idx, bw, showBoth, routes = this.am.getRoutes(), genRoutes = routes.filter(r => routeFamily(r) === null), familyTarget = {}, showFamily = true, nameW = NAME_MIN) {
|