@rikcodes/teamclaude 1.1.20-rik.15 → 1.1.20-rik.17

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
@@ -65,6 +65,7 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
65
65
  - Pools OpenAI Codex subscriptions alongside Claude accounts (experimental): the Codex CLI is routed through the same proxy, by config or transparently through the MITM proxy, and rotates on its own quota.
66
66
  - Takes any Anthropic-compatible API (DeepSeek, GLM) as a low-priority fallback for when the Claude accounts are done.
67
67
  - Serves OpenAI models next to Claude ones — a supervised local sidecar translates `gpt-*` requests onto a ChatGPT subscription, with real model names in `/model` and GPT subagents dispatchable from a Claude parent (`sidecars` + `customModels`, this fork).
68
+ - Ships those subagents as a Claude Code plugin: one named agent per model **and effort level**, which the Agent tool's `model` parameter cannot express (`/plugin install rikclaude-agents@rikclaude`, this fork).
68
69
  - No dependencies. Node built-ins only.
69
70
 
70
71
  ## Everyday commands
@@ -152,10 +153,10 @@ At launch, `teamclaude run` — and the `claude` alias, which passes through `ru
152
153
  | Row field | Where it ends up |
153
154
  | --- | --- |
154
155
  | `model`, `label`, `description` | A `/model` picker row under the **real** model id (`--settings`), so `/model gpt-5.6-sol` works picked or typed |
155
- | `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/` |
156
+ | `model` | Nothing by default. Set `"customModelAgents": true` and each row also becomes a plain subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent. Off because those agents pin no effort and outrank every agent file on disk — prefer the effort-pinned [plugin agents](docs/agents.md) |
156
157
  | `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 |
157
158
 
158
- 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.
159
+ 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. Agents do not depend on either mode: they are read from disk, whether they come from the [`rikclaude-agents` plugin](docs/agents.md) or from a `~/.claude/agents/<name>.md` you write with `model: gpt-5.6-terra` in its frontmatter.
159
160
 
160
161
  Each request is routed by the model name in its body, so one session can freely mix models: use `claude --model gpt-5.6-sol` for a whole session, `/model gpt-5.6-sol` during a session, or a Claude parent that dispatches a GPT subagent.
161
162
 
@@ -240,8 +241,10 @@ This feature is on by default. Each account row shows which window binds first:
240
241
  | [Routing](docs/routing.md) | Rotation, the two kinds of 429, storm control, model routes, session spreading, pinning, prompt cache |
241
242
  | [Quota](docs/quota.md) | Quota probe, keep-warm, holding on exhaustion |
242
243
  | [OpenAI models](docs/openai.md) | Codex sidecar setup, custom model registration, GPT subagents, limitations |
244
+ | [Subagents](docs/agents.md) | The `rikclaude-agents` plugin: effort-pinned agents per model, installing it, seeding it for a team |
243
245
  | [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
244
246
  | [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
247
+ | [Remote host](docs/remote.md) | Running the fleet on an always-on box: reaching it, moving the accounts, service supervision, pointing clients at it |
245
248
  | [Compliance](docs/compliance.md) | Terms of service notes |
246
249
 
247
250
  ## Releasing this fork
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.15",
3
+ "version": "1.1.20-rik.17",
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
@@ -339,6 +383,7 @@ export class AccountManager {
339
383
  // `distributeSessions` gates the behavioural change: keep each session on its
340
384
  // account for cache reuse, but spread NEW sessions across equal-priority
341
385
  // accounts by load instead of funnelling them all onto the current one.
386
+ /** @type {SessionTracker} */
342
387
  this.sessionTracker = sessionTracker || new SessionTracker();
343
388
  // 'off' | 'even' | 'adaptive'. `distributeSessions` stays a boolean beside
344
389
  // it ("is distribution on at all") because the status readout, the TUI
@@ -726,6 +771,12 @@ export class AccountManager {
726
771
  * bucket, so the account must be eligible for both models. When no account
727
772
  * satisfies both, selection degrades to executor-only routing so the main
728
773
  * request keeps flowing (upstream then fails just the advisor call).
774
+ *
775
+ * `sessionId` is a PIN KEY throughout this class and the session tracker: a
776
+ * client session narrowed to the conversation within it, since one client
777
+ * session fans out across many (see conversation.js). It is spelled
778
+ * `sessionId` because it is one for every request that names no conversation,
779
+ * and because the affinity it drives is the same affinity it always was.
729
780
  */
730
781
  getActiveAccount(exclude = null, model = null, advisorModel = null, sessionId = null, provider = DEFAULT_PROVIDER, decision = null) {
731
782
  // Selection reads this.currentIndex as "where the fleet is". With more than
@@ -852,11 +903,15 @@ export class AccountManager {
852
903
  // The empty set marks this call as a request's, since a poll hands none.
853
904
  // Allocated fresh: a set handed out once is one a later reader could add to.
854
905
  this.refreshExpiredQuotas(model, this.expiryRouting.enabled ? (exclude ?? new Set()) : exclude, advisorModel);
855
- // Session-affinity distribution (opt-in): keep a session on its pinned
856
- // account for cache reuse, and route a new session to the least-loaded
857
- // account. Only when enabled, only for a real session, and only outside a
858
- // manual route pin (which must still win). Falls through to the normal walk
859
- // if nothing session-eligible is found (e.g. the whole tier is exhausted).
906
+ // Session-affinity distribution (opt-in): keep a conversation on its pinned
907
+ // account for cache reuse, and route a new one to the least-loaded account.
908
+ // The unit is the conversation rather than the client session because one
909
+ // session fans out — a Claude Code session and every subagent it launches
910
+ // share its id, and holding them together pinned a whole fan-out to one
911
+ // account for a cache none of them shared (see conversation.js). Only when
912
+ // enabled, only for a request that named one, and only outside a manual
913
+ // route pin (which must still win). Falls through to the normal walk if
914
+ // nothing eligible is found (e.g. the whole tier is exhausted).
860
915
  if (sessionId && !this._pinnedAccountForModel(model, advisorModel)) {
861
916
  if (this.distributeSessions) {
862
917
  const acc = this._selectForSession(sessionId, exclude, model, advisorModel);
@@ -1010,13 +1065,18 @@ export class AccountManager {
1010
1065
  // (_isAvailable, below) rather than a second key.
1011
1066
  const bucket = this._weeklyBucketFor(model);
1012
1067
  const pinIdx = this.sessionTracker.pinnedAccount(sessionId, bucket);
1013
- // The bucket's own pin first. Failing that, any account the session already
1014
- // sits on for another family: one session stays on one account unless that
1015
- // account cannot serve the request (the README's "pins it there"). Without
1016
- // this, a session's first request of a second family would go to
1017
- // _pickLeastLoaded, which counts the session's own pin as load and pushes
1018
- // the new family onto a sibling — splitting every mixed-model session
1019
- // across two accounts by construction, not only on a real diversion.
1068
+ // The bucket's own pin first. Failing that, any account this conversation
1069
+ // already sits on for another family: one conversation stays on one account
1070
+ // unless that account cannot serve the request (the README's "pins it
1071
+ // there"). Without this, its first request of a second family would go to
1072
+ // _pickLeastLoaded, which counts the conversation's own pin as load and
1073
+ // pushes the new family onto a sibling — splitting every mixed-model
1074
+ // conversation across two accounts by construction, not only on a real
1075
+ // diversion.
1076
+ //
1077
+ // Scoped to the one conversation, not to everything sharing its session id:
1078
+ // two agents of one fan-out are not each other's affinity, and treating
1079
+ // them as one is what funnelled them onto a single account.
1020
1080
  const candidates = [];
1021
1081
  if (pinIdx != null) candidates.push(pinIdx);
1022
1082
  for (const idx of this.sessionTracker.pinnedAccounts(sessionId)) {
@@ -1310,6 +1370,18 @@ export class AccountManager {
1310
1370
  this.sessionTracker.recordOutcome(sessionId, usable);
1311
1371
  }
1312
1372
 
1373
+ /**
1374
+ * The same, for an exit that can name only the client session — every live
1375
+ * conversation of it takes the outcome (see SessionTracker).
1376
+ *
1377
+ * @param {string|null} sessionId
1378
+ * @param {boolean|null} usable
1379
+ */
1380
+ recordOutcomeForSession(sessionId, usable) {
1381
+ if (!sessionId || usable === null) return;
1382
+ this.sessionTracker.recordOutcomeForSession(sessionId, usable);
1383
+ }
1384
+
1313
1385
  /**
1314
1386
  * Per-account diagnostics for adaptive distribution: the profile plan tier,
1315
1387
  * relative score weight, and actual next target.
@@ -3602,6 +3674,19 @@ export class AccountManager {
3602
3674
  && this.accounts.some(a => providerOf(a) === 'codex');
3603
3675
  }
3604
3676
 
3677
+ /**
3678
+ * Whether this account merely relays to our own Codex pool, for a caller
3679
+ * outside this class that must not treat a relayed reading as the account's
3680
+ * own. The private form above is the definition; this is the same question
3681
+ * asked from the request path, where the distinction decides whether a quota
3682
+ * rejection describes the account in hand or one behind it.
3683
+ *
3684
+ * @param {any} account
3685
+ */
3686
+ isCodexConduit(account) {
3687
+ return this._isCodexConduit(account);
3688
+ }
3689
+
3605
3690
  /**
3606
3691
  * Update cumulative token usage from response body data.
3607
3692
  */
@@ -3796,6 +3881,9 @@ export class AccountManager {
3796
3881
  applyCodexUsageData(accountIndex, usage) {
3797
3882
  const account = this.accounts[accountIndex];
3798
3883
  if (!account || !usage || usage.error) return;
3884
+ // The three Codex-learned fields below are written here for the first time,
3885
+ // so the empty-quota shape does not carry them. See CodexLearnedQuota.
3886
+ /** @type {typeof account.quota & CodexLearnedQuota} */
3799
3887
  const q = account.quota;
3800
3888
  if (usage.fiveHour) {
3801
3889
  q.unified5h = usage.fiveHour.utilization;
@@ -3806,6 +3894,10 @@ export class AccountManager {
3806
3894
  q.unified7dReset = usage.sevenDay.resetAt ?? null;
3807
3895
  }
3808
3896
  if (usage.planType) q.planType = safeLine(usage.planType, 64);
3897
+ // Stamped, because nothing else refreshes it: a payload that mentions no
3898
+ // credits leaves the last reading alone rather than blanking it, so the
3899
+ // age is the only thing that says how much the number is worth.
3900
+ if (usage.resetCredits) q.resetCredits = { ...usage.resetCredits, seenAt: Date.now() };
3809
3901
  if (Array.isArray(usage.modelBuckets)) {
3810
3902
  q.codexModelBuckets = Object.fromEntries(usage.modelBuckets.slice(0, MAX_CODEX_MODEL_BUCKETS)
3811
3903
  .filter(bucket => bucket?.slug)
@@ -4132,6 +4224,20 @@ export class AccountManager {
4132
4224
  for (const f of PERSISTED_QUOTA_FIELDS) {
4133
4225
  if (match.quota[f] != null) account.quota[f] = match.quota[f];
4134
4226
  }
4227
+ // Both writers of the Codex bucket table cap it, because its keys are
4228
+ // upstream header names; restoring is the one way in that never passed a
4229
+ // cap. A file we wrote cannot be over the ceiling, but an edited or
4230
+ // half-written one can, and it would then stand until some slug this
4231
+ // server has never seen turns up to evict the surplus. Newest readings
4232
+ // kept, which is the same order the eviction there works in.
4233
+ /** @type {typeof account.quota & CodexLearnedQuota} */
4234
+ const quota = account.quota;
4235
+ const restoredBuckets = quota.codexModelBuckets;
4236
+ if (restoredBuckets && Object.keys(restoredBuckets).length > MAX_CODEX_MODEL_BUCKETS) {
4237
+ quota.codexModelBuckets = Object.fromEntries(Object.entries(restoredBuckets)
4238
+ .sort((a, b) => (b[1]?.seenAt || 0) - (a[1]?.seenAt || 0))
4239
+ .slice(0, MAX_CODEX_MODEL_BUCKETS));
4240
+ }
4135
4241
  for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
4136
4242
  if (match.profile?.[field] != null) account[field] = match.profile[field];
4137
4243
  }
@@ -110,6 +110,18 @@ function classify(windows) {
110
110
  return out;
111
111
  }
112
112
 
113
+ /**
114
+ * A parsed reading: the fields `account.quota` already uses, each present only
115
+ * when the headers stated it.
116
+ *
117
+ * @typedef {object} CodexQuota
118
+ * @property {number} [unified5h] Session-window utilization, 0-1.
119
+ * @property {number} [unified5hReset] When that window resets, ms epoch.
120
+ * @property {number} [unified7d] Weekly-window utilization, 0-1.
121
+ * @property {number} [unified7dReset] When that window resets, ms epoch.
122
+ * @property {{slug: string, name: string, utilization: number, resetAt: number|null}[]} [modelBuckets] Model-scoped weekly buckets.
123
+ */
124
+
113
125
  /**
114
126
  * Parse Codex rate-limit headers into the fields `account.quota` already uses.
115
127
  *
@@ -117,9 +129,12 @@ function classify(windows) {
117
129
  * an existing quota without blanking readings this response did not mention.
118
130
  * An empty object means "this response carried no quota", which is normal:
119
131
  * the catalog fetch (`/models`) has none.
132
+ *
133
+ * @returns {CodexQuota}
120
134
  */
121
135
  export function parseCodexQuota(headers) {
122
136
  const families = collectFamilies(headers);
137
+ /** @type {CodexQuota} */
123
138
  const quota = {};
124
139
 
125
140
  const account = classify(families.get('')?.windows || {});
@@ -172,6 +187,52 @@ export function parseCodexQuota(headers) {
172
187
  return quota;
173
188
  }
174
189
 
190
+ /**
191
+ * Is a window at or past its limit?
192
+ *
193
+ * Readings are 0-1 fractions here, and the comparison is `>=` because upstream
194
+ * keeps counting once a window is past its limit: 104% arrives as 1.04. A
195
+ * window the headers did not state is not spent — `classify` has already
196
+ * dropped the unparseable and the zeroed.
197
+ *
198
+ * @param {number} [utilization]
199
+ */
200
+ const isSpent = (utilization) => utilization != null && utilization >= 1;
201
+
202
+ /**
203
+ * Which windows these headers report as spent — at or past their limit. Empty
204
+ * when none are, including when the response carried no Codex quota at all.
205
+ *
206
+ * Anthropic names a spent bucket outright, as `…-status: rejected`. This API
207
+ * publishes no status at all: it reports how much of each window is gone, and a
208
+ * window at its limit is the same fact in the other spelling. That distinction
209
+ * is what a 429 handler needs, because a spent window is durable — the account
210
+ * cannot serve again until the window resets, so waiting out `retry-after` and
211
+ * asking the SAME account again is futile, however momentary the 429 looked.
212
+ *
213
+ * Every family is read, not just the account-wide one. A subscription states
214
+ * its only 5-hour window inside a NAMED family (see parseCodexQuota), so the
215
+ * account-wide percentages alone would never show a spent session window; and a
216
+ * model-scoped weekly bucket is spent on its own terms, whatever the
217
+ * account-wide reading says.
218
+ *
219
+ * The labels name what is spent, for the log line that follows. A caller that
220
+ * only wants the verdict tests the length.
221
+ *
222
+ * @param {Record<string, string>} headers Rate-limit headers from the response.
223
+ * @returns {string[]} One label per spent window, e.g. `['weekly']`.
224
+ */
225
+ export function codexSpentWindows(headers) {
226
+ const quota = parseCodexQuota(headers);
227
+ const spent = [];
228
+ if (isSpent(quota.unified5h)) spent.push('5h');
229
+ if (isSpent(quota.unified7d)) spent.push('weekly');
230
+ for (const bucket of quota.modelBuckets ?? []) {
231
+ if (isSpent(bucket.utilization)) spent.push(`${bucket.name} weekly`);
232
+ }
233
+ return spent;
234
+ }
235
+
175
236
  /** The subscription plan upstream reports, for status output. Null when absent. */
176
237
  export function parseCodexPlanType(headers) {
177
238
  const value = headers?.['x-codex-plan-type'];