@rikcodes/teamclaude 1.1.20-rik.14 → 1.1.20-rik.16

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 CHANGED
@@ -107,10 +107,12 @@ A Claude Code session can use OpenAI models alongside the Claude accounts. They
107
107
  **1. Install the sidecar and log it into your ChatGPT account** (one time):
108
108
 
109
109
  ```bash
110
- brew install raine/claude-code-proxy/claude-code-proxy
110
+ curl -fsSL https://raw.githubusercontent.com/rikbrown/claude-code-proxy/rik/main/scripts/install.sh | bash
111
111
  claude-code-proxy codex auth login
112
112
  ```
113
113
 
114
+ This installs [rikbrown/claude-code-proxy](https://github.com/rikbrown/claude-code-proxy), a fork of the reference sidecar. Take the fork rather than upstream's Homebrew build: it forwards Codex's quota headers, without which every bar on the account reads `unknown`, and it lets you raise the 60-second header timeout that otherwise kills a long reasoning turn. See [Timeouts](docs/openai.md#timeouts).
115
+
114
116
  **2. Connect it** — add four pieces to `~/.config/teamclaude.json`:
115
117
 
116
118
  ```json
@@ -161,11 +163,11 @@ Each request is routed by the model name in its body, so one session can freely
161
163
 
162
164
  1. Check that your sidecar build lists it: `curl -s http://127.0.0.1:18765/v1/models`. The sidecar has its own allow-list and rejects any id that it does not know, regardless of the TeamClaude configuration. Upgrade the sidecar if the id is missing.
163
165
  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
- 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.
166
+ 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. The first `SIGTERM` only starts a graceful shutdown, which a request in flight holds open; send it a second time to force the exit.
165
167
 
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.
168
+ 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, which the fork build in step 1 does and upstream's does not — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
167
169
 
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`).
170
+ 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`), followed by what the sidecar reports about itself while it is up (`2 active 3 errors`, read from its own `/monitor` endpoint; each is omitted at zero, and both are omitted if that endpoint does not answer).
169
171
 
170
172
  #### Several ChatGPT accounts
171
173
 
@@ -221,7 +223,7 @@ Three details matter:
221
223
  - **`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
224
  - **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
225
 
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.
226
+ One thing differs from the single-account setup: each turn appears **twice** in the activity list, once per hop. Tokens and quota bars both come from the second hop, where the subscription is, so they are booked against the ChatGPT account that served. The sidecar account holds a stub login and spends nothing of its own, so it reads `N req · 0 tok` — booking it as well would double every figure.
225
227
 
226
228
  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).
227
229
 
@@ -240,6 +242,7 @@ This feature is on by default. Each account row shows which window binds first:
240
242
  | [OpenAI models](docs/openai.md) | Codex sidecar setup, custom model registration, GPT subagents, limitations |
241
243
  | [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
242
244
  | [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
245
+ | [Remote host](docs/remote.md) | Running the fleet on an always-on box: reaching it, moving the accounts, service supervision, pointing clients at it |
243
246
  | [Compliance](docs/compliance.md) | Terms of service notes |
244
247
 
245
248
  ## Releasing this fork
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.14",
3
+ "version": "1.1.20-rik.16",
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",
@@ -100,6 +100,23 @@ const PERSISTED_QUOTA_FIELDS = [
100
100
  'unifiedStatus', 'unifiedStatusSeenAt',
101
101
  'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
102
102
  'scopedWeekly',
103
+ // Codex free rate-limit reset credits, `{ available, applicable, seenAt }`.
104
+ // Worth persisting although it is not a quota: the usage probe is off by
105
+ // default, so without this a restart forgets that an account holds a credit
106
+ // until something next reads /wham/usage — and the row that says so is the
107
+ // only place an operator sees one at all.
108
+ 'resetCredits',
109
+ // The Codex subscription tier, a string from the response headers or the
110
+ // usage payload. Both sources are traffic, so without this a restarted server
111
+ // cannot name an account's plan until it next serves a request — and a plan
112
+ // an account is on does not change over a restart.
113
+ 'planType',
114
+ // Codex's model-scoped weekly buckets, `{ [slug]: { name, utilization,
115
+ // resetAt, seenAt } }`. Learned the same way and just as lossy on restart,
116
+ // and the per-entry `seenAt` is load-bearing beyond the reading itself: it is
117
+ // what orders the eviction that keeps the table under its cap, so dropping
118
+ // the table also drops the history that decides what makes room next.
119
+ 'codexModelBuckets',
103
120
  ];
104
121
 
105
122
  // The family (Fable/Sonnet) weekly buckets and the field holding when each was
@@ -127,6 +144,22 @@ function parseResetAt(value) {
127
144
  return Number.isNaN(parsed) ? null : parsed;
128
145
  }
129
146
 
147
+ /**
148
+ * The quota fields a Codex account only ever LEARNS — from a `/wham/usage`
149
+ * payload or from the state restored off disk — so `emptyQuota` below does not
150
+ * seed them. Absent is meaningful for each: it says nothing has been read yet,
151
+ * which a seeded null would spell the same way as "read, and empty".
152
+ *
153
+ * Declared here so the few places that write them can say so (`@type` on the
154
+ * local that holds the quota), rather than each one reading as a property that
155
+ * does not exist.
156
+ *
157
+ * @typedef {object} CodexLearnedQuota
158
+ * @property {string} [planType] the Codex subscription tier
159
+ * @property {{available: number, applicable: number|null, seenAt: number}} [resetCredits] free rate-limit reset credits held, and when that was last seen
160
+ * @property {Record<string, {name: string, utilization: number, resetAt: number|null, seenAt: number}>} [codexModelBuckets] model-scoped weekly buckets, keyed by slug
161
+ */
162
+
130
163
  function emptyQuota() {
131
164
  return {
132
165
  // Standard API rate limits (API key accounts)
@@ -223,6 +256,17 @@ function makeAccount(acct, index) {
223
256
  displayOrder: Number.isFinite(acct.displayOrder) ? acct.displayOrder : null,
224
257
  disabled: acct.disabled || false,
225
258
  maxUsage: acct.maxUsage ?? null,
259
+ // Whether this account is EXEMPT from spending one of its free Codex
260
+ // rate-limit reset credits (see codex-reset-credits.js). Negative-only, and
261
+ // the polarity is the opposite of what the name suggests: the switch that
262
+ // arms anything is the fleet-wide `autoRedeemResets`, because the policy it
263
+ // arms ("only when the whole Codex pool is dry") is a statement about the
264
+ // fleet. All this key can say is "never this one", so `true` and an absent
265
+ // key mean exactly the same thing here. Meaningless on an Anthropic
266
+ // account, which has no such credits — the redeemer checks the provider
267
+ // rather than making the field's default depend on it, so a config moved
268
+ // between providers keeps saying the same thing.
269
+ autoRedeemReset: acct.autoRedeemReset !== false,
226
270
  upstream: acct.upstream || null,
227
271
  modelMap: acct.modelMap || null,
228
272
  // Fields to drop from request bodies for this account (third-party upstreams
@@ -3492,7 +3536,9 @@ export class AccountManager {
3492
3536
  // threshold and every request fails, while a sibling sits at 0%.
3493
3537
  // A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
3494
3538
  // its own login, the forwarded numbers are its own, and they still apply.
3495
- if (!(isLocalUpstream(account) && this.accounts.some(a => providerOf(a) === 'codex'))) {
3539
+ // Shares its definition with the token counters, which drop a conduit hop
3540
+ // for the same reason: what it reports belongs to whoever served.
3541
+ if (!this._isCodexConduit(account)) {
3496
3542
  for (const window of ['primary', 'secondary']) {
3497
3543
  const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
3498
3544
  const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
@@ -3563,12 +3609,62 @@ export class AccountManager {
3563
3609
  }
3564
3610
  }
3565
3611
 
3612
+ /**
3613
+ * Whether `account` merely RELAYS to this fleet's own Codex pool instead of
3614
+ * holding a subscription of its own: a translating sidecar whose back leg is
3615
+ * pointed back at this proxy, so each turn crosses this process twice — once
3616
+ * inbound on `/v1/messages` and once outbound on `/backend-api/codex/*`
3617
+ * (docs/openai.md, "Several ChatGPT accounts behind one sidecar").
3618
+ *
3619
+ * Keyed on three things, all of which the documented setup has and no other
3620
+ * account does:
3621
+ *
3622
+ * a loopback upstream the sidecar runs on this machine.
3623
+ * the Anthropic wire a conduit is reached on `/v1/messages`, so it
3624
+ * carries no `provider` field — docs/openai.md warns
3625
+ * that giving one `"provider": "codex"` puts it in the
3626
+ * foreign-subscription partition and every `gpt-*`
3627
+ * request then fails to find an account. A pooled
3628
+ * ChatGPT account is therefore never a conduit,
3629
+ * whatever its upstream says; it IS the pool.
3630
+ * a pool to relay to a standalone sidecar holding its own ChatGPT login
3631
+ * is NOT a conduit: everything it reports is its own,
3632
+ * and it is the only hop there is.
3633
+ *
3634
+ * Still imprecise in one direction, deliberately: a fleet running BOTH a
3635
+ * self-hosting local Anthropic backend and Codex accounts reads the former as
3636
+ * a conduit. Narrowing that would mean correlating the two hops of one turn,
3637
+ * which nothing here can do — they are separate requests sharing no id — and
3638
+ * the cost of the false positive is a row that under-reports rather than one
3639
+ * that misroutes.
3640
+ *
3641
+ * @param {any} account
3642
+ */
3643
+ _isCodexConduit(account) {
3644
+ return isLocalUpstream(account)
3645
+ && providerOf(account) === DEFAULT_PROVIDER
3646
+ && this.accounts.some(a => providerOf(a) === 'codex');
3647
+ }
3648
+
3649
+ /**
3650
+ * Whether this account merely relays to our own Codex pool, for a caller
3651
+ * outside this class that must not treat a relayed reading as the account's
3652
+ * own. The private form above is the definition; this is the same question
3653
+ * asked from the request path, where the distinction decides whether a quota
3654
+ * rejection describes the account in hand or one behind it.
3655
+ *
3656
+ * @param {any} account
3657
+ */
3658
+ isCodexConduit(account) {
3659
+ return this._isCodexConduit(account);
3660
+ }
3661
+
3566
3662
  /**
3567
3663
  * Update cumulative token usage from response body data.
3568
3664
  */
3569
3665
  updateUsage(accountIndex, inputTokens, outputTokens) {
3570
3666
  const account = this.accounts[accountIndex];
3571
- if (!account) return;
3667
+ if (!account || this._isCodexConduit(account)) return;
3572
3668
  if (inputTokens) account.usage.totalInputTokens += inputTokens;
3573
3669
  if (outputTokens) account.usage.totalOutputTokens += outputTokens;
3574
3670
  }
@@ -3588,6 +3684,22 @@ export class AccountManager {
3588
3684
  */
3589
3685
  recordTokenUsage(accountIndex, sessionId, model, usage) {
3590
3686
  if (!usage) return;
3687
+ // A conduit hop is the SAME tokens, translated: the sidecar rebuilt this
3688
+ // report out of the Responses usage the pool sent it, and the pool's own hop
3689
+ // records the original a moment later. Both scopes would double — the two
3690
+ // account rows are distinct, but a session is one row and carries the same
3691
+ // id on both hops, so its context and spend would read twice the truth.
3692
+ //
3693
+ // Dropped rather than deduplicated because there is nothing to deduplicate
3694
+ // against: the two hops are separate requests that share no id, and the
3695
+ // conduit spends nothing of its own anyway (its login is a stub). Booking at
3696
+ // the hop that really spent is what keeps one turn one record.
3697
+ //
3698
+ // Only the ACCOUNT-scoped and SESSION-scoped counters stop here. Per-client
3699
+ // attribution still runs on the inbound hop, in the caller, because that is
3700
+ // the only hop that can see who asked: the outbound one comes from the
3701
+ // sidecar on loopback and carries no client identity at all.
3702
+ if (this._isCodexConduit(this.accounts[accountIndex])) return;
3591
3703
  // The same resolver routing uses, so a token total and a routing decision
3592
3704
  // agree about which family a request belonged to. Resolved here rather than
3593
3705
  // at the call sites: they parse a wire format and have no business knowing
@@ -3741,6 +3853,9 @@ export class AccountManager {
3741
3853
  applyCodexUsageData(accountIndex, usage) {
3742
3854
  const account = this.accounts[accountIndex];
3743
3855
  if (!account || !usage || usage.error) return;
3856
+ // The three Codex-learned fields below are written here for the first time,
3857
+ // so the empty-quota shape does not carry them. See CodexLearnedQuota.
3858
+ /** @type {typeof account.quota & CodexLearnedQuota} */
3744
3859
  const q = account.quota;
3745
3860
  if (usage.fiveHour) {
3746
3861
  q.unified5h = usage.fiveHour.utilization;
@@ -3751,6 +3866,10 @@ export class AccountManager {
3751
3866
  q.unified7dReset = usage.sevenDay.resetAt ?? null;
3752
3867
  }
3753
3868
  if (usage.planType) q.planType = safeLine(usage.planType, 64);
3869
+ // Stamped, because nothing else refreshes it: a payload that mentions no
3870
+ // credits leaves the last reading alone rather than blanking it, so the
3871
+ // age is the only thing that says how much the number is worth.
3872
+ if (usage.resetCredits) q.resetCredits = { ...usage.resetCredits, seenAt: Date.now() };
3754
3873
  if (Array.isArray(usage.modelBuckets)) {
3755
3874
  q.codexModelBuckets = Object.fromEntries(usage.modelBuckets.slice(0, MAX_CODEX_MODEL_BUCKETS)
3756
3875
  .filter(bucket => bucket?.slug)
@@ -4077,6 +4196,20 @@ export class AccountManager {
4077
4196
  for (const f of PERSISTED_QUOTA_FIELDS) {
4078
4197
  if (match.quota[f] != null) account.quota[f] = match.quota[f];
4079
4198
  }
4199
+ // Both writers of the Codex bucket table cap it, because its keys are
4200
+ // upstream header names; restoring is the one way in that never passed a
4201
+ // cap. A file we wrote cannot be over the ceiling, but an edited or
4202
+ // half-written one can, and it would then stand until some slug this
4203
+ // server has never seen turns up to evict the surplus. Newest readings
4204
+ // kept, which is the same order the eviction there works in.
4205
+ /** @type {typeof account.quota & CodexLearnedQuota} */
4206
+ const quota = account.quota;
4207
+ const restoredBuckets = quota.codexModelBuckets;
4208
+ if (restoredBuckets && Object.keys(restoredBuckets).length > MAX_CODEX_MODEL_BUCKETS) {
4209
+ quota.codexModelBuckets = Object.fromEntries(Object.entries(restoredBuckets)
4210
+ .sort((a, b) => (b[1]?.seenAt || 0) - (a[1]?.seenAt || 0))
4211
+ .slice(0, MAX_CODEX_MODEL_BUCKETS));
4212
+ }
4080
4213
  for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
4081
4214
  if (match.profile?.[field] != null) account[field] = match.profile[field];
4082
4215
  }
package/src/claude-env.js CHANGED
@@ -76,6 +76,66 @@ export function mergeNoProxy(...inherited) {
76
76
  return out.join(',');
77
77
  }
78
78
 
79
+ /** The two ways a launched client can reach the proxy. */
80
+ export const CLIENT_MODES = ['mitm', 'base-url'];
81
+
82
+ /**
83
+ * Which mode `run` and `env` use: a `--mitm` or `--no-mitm` flag decides, else
84
+ * the config's `defaultClientMode`, else MITM.
85
+ *
86
+ * MITM routes every request of the launched client through the proxy, so the
87
+ * hard-coded api.anthropic.com endpoints and the Codex CLI (which honours only
88
+ * proxy variables) are covered. It is also the whole point of the setting: with
89
+ * `eval "$(teamclaude env)"` the proxy variables are shell-wide, and every other
90
+ * tool in that shell — gh, git, a package manager — follows them to a listener
91
+ * that only speaks to two hosts (#382). An operator who lives in such a shell
92
+ * sets `defaultClientMode: "base-url"` once and opts back in per launch.
93
+ *
94
+ * @param {{ defaultClientMode?: string }|null|undefined} config
95
+ * @param {string[]} flags the invocation's own arguments
96
+ * @returns {'mitm'|'base-url'}
97
+ */
98
+ export function resolveClientMode(config, flags) {
99
+ const mitm = flags.includes('--mitm');
100
+ const noMitm = flags.includes('--no-mitm');
101
+ if (mitm && noMitm) throw new Error('choose either --mitm or --no-mitm');
102
+ if (mitm) return 'mitm';
103
+ if (noMitm) return 'base-url';
104
+ return config?.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
105
+ }
106
+
107
+ const SHELL_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'];
108
+
109
+ /**
110
+ * @param {unknown} value
111
+ * @param {number} port
112
+ */
113
+ function pointsAtLoopback(value, port) {
114
+ if (!value) return false;
115
+ try {
116
+ const url = new URL(String(value));
117
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
118
+ return ['127.0.0.1', 'localhost', '::1'].includes(host) && Number(url.port || 80) === port;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * `unset` lines for proxy variables a previous MITM-mode eval left in the
126
+ * shell, so re-evaluating in base-URL mode takes the proxy back out of it. Only
127
+ * a value naming THIS proxy's loopback port is touched: a real corporate proxy
128
+ * in the same variables is the operator's and stays.
129
+ *
130
+ * @param {unknown} port
131
+ * @param {NodeJS.ProcessEnv} [env]
132
+ * @returns {string[]}
133
+ */
134
+ export function clearSelfProxyEnvLines(port, env = process.env) {
135
+ const checkedPort = validPort(port);
136
+ return SHELL_PROXY_VARS.filter(name => pointsAtLoopback(env[name], checkedPort)).map(name => `unset ${name}`);
137
+ }
138
+
79
139
  // Build the shell `export` lines that point Claude Code — or any tool that
80
140
  // spawns it, e.g. an agent multiplexer — at the proxy. This is the same
81
141
  // environment `teamclaude run` sets up, but emitted for `eval "$(teamclaude