@rikcodes/teamclaude 1.1.20-rik.1 → 1.1.20-rik.10
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 +62 -4
- package/package.json +1 -1
- package/src/account-manager.js +122 -39
- package/src/codex-quota.js +35 -3
- package/src/codex-usage.js +55 -4
- package/src/config.js +1 -0
- package/src/crash-log.js +37 -4
- package/src/dashboard.js +13 -4
- package/src/identity.js +45 -0
- package/src/index.js +62 -11
- package/src/mitm.js +18 -1
- package/src/provider.js +42 -0
- package/src/route-warnings.js +60 -0
- package/src/server.js +142 -16
- package/src/sidecar.js +111 -4
- package/src/status-renderer.js +16 -1
- package/src/sync-accounts.js +18 -3
- package/src/tui-remote.js +24 -1
- package/src/tui.js +170 -19
- package/src/updater.js +54 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# TeamClaude
|
|
2
2
|
|
|
3
3
|
> **Fork notice (rikbrown).** This fork adds two features on top of
|
|
4
|
-
> [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude), currently based on upstream 1.1.
|
|
4
|
+
> [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude), currently based on upstream 1.1.20:
|
|
5
5
|
>
|
|
6
6
|
> - **[OpenAI models via a Codex sidecar](docs/openai.md)** (`sidecars` + `customModels`, opt-in):
|
|
7
7
|
> route `gpt-*` requests through a supervised local translating proxy to a ChatGPT subscription,
|
|
@@ -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.10",
|
|
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';
|
|
@@ -221,6 +221,13 @@ function makeAccount(acct, index) {
|
|
|
221
221
|
// Fields to drop from request bodies for this account (third-party upstreams
|
|
222
222
|
// that reject e.g. `context_management`). See server.js stripBodyFields.
|
|
223
223
|
stripRequestFields: acct.stripRequestFields || null,
|
|
224
|
+
// Whether this upstream keeps Anthropic message-thread state. Off for a
|
|
225
|
+
// third-party backend, which would otherwise be handed a bare delta. See
|
|
226
|
+
// server.js refusesThreadContinue.
|
|
227
|
+
messageThreads: acct.messageThreads === true,
|
|
228
|
+
// Whether the operator has already been told this upstream keeps no thread
|
|
229
|
+
// state, so the line is printed once rather than per refusal.
|
|
230
|
+
threadRefusalReported: false,
|
|
224
231
|
// Per-account response-headers deadline (ms); null means the fleet default
|
|
225
232
|
// in upstream-fetch.js. See normalizeHeadersTimeoutMs.
|
|
226
233
|
headersTimeoutMs: normalizeHeadersTimeoutMs(acct.headersTimeoutMs),
|
|
@@ -355,6 +362,10 @@ export class AccountManager {
|
|
|
355
362
|
// it. Each such switch arms the ramp below, so steady interleaved traffic
|
|
356
363
|
// holds both accounts at the ramp floor while nothing has failed over.
|
|
357
364
|
this.routeCursors = new Map();
|
|
365
|
+
/** Rotation for requests that carry no session id; separate from the shared
|
|
366
|
+
* cursor so turning it never moves a running session's account.
|
|
367
|
+
* @type {number} */
|
|
368
|
+
this._untaggedCursor = 0;
|
|
358
369
|
// One cursor per provider. `currentIndex` is a single slot, and a request
|
|
359
370
|
// only another provider can serve would otherwise drag it across: a Codex
|
|
360
371
|
// request moved it onto a Codex account and the next Anthropic request moved
|
|
@@ -688,7 +699,7 @@ export class AccountManager {
|
|
|
688
699
|
if (!account?.entitlementDeniedUntil) return false;
|
|
689
700
|
if (now < account.entitlementDeniedUntil) return true;
|
|
690
701
|
account.entitlementDeniedUntil = null;
|
|
691
|
-
console.log(`[TeamClaude] Account "${account.name}" entitlement cooldown expired, marking available`);
|
|
702
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" entitlement cooldown expired, marking available`);
|
|
692
703
|
return false;
|
|
693
704
|
}
|
|
694
705
|
|
|
@@ -851,6 +862,12 @@ export class AccountManager {
|
|
|
851
862
|
if (acc) return acc;
|
|
852
863
|
}
|
|
853
864
|
}
|
|
865
|
+
// An untagged request has no session to pin, so the walk below rests every
|
|
866
|
+
// one of them on the current account. Spread them too, on their own cursor.
|
|
867
|
+
if (!sessionId && this.distributeSessions && !this._pinnedAccountForModel(model, advisorModel)) {
|
|
868
|
+
const acc = this._selectUntagged(exclude, model, advisorModel);
|
|
869
|
+
if (acc) return acc;
|
|
870
|
+
}
|
|
854
871
|
if (advisorModel) {
|
|
855
872
|
const account = this._select(exclude, model, advisorModel, false);
|
|
856
873
|
if (account) return account;
|
|
@@ -944,6 +961,41 @@ export class AccountManager {
|
|
|
944
961
|
* eligible account. Returns null if nothing is eligible, so the caller falls
|
|
945
962
|
* back to the normal quota-driven walk. Does NOT record the pin — that happens
|
|
946
963
|
* on the actual route (recordSession), so retries/failover re-pin naturally. */
|
|
964
|
+
/**
|
|
965
|
+
* Round-robin for requests that carry no session id.
|
|
966
|
+
*
|
|
967
|
+
* The Codex CLI tags `POST /responses` with `session-id` but not its catalog
|
|
968
|
+
* fetch, and Claude Code tags `/v1/messages` but not its telemetry. Those
|
|
969
|
+
* untagged requests have nothing to pin, so the walk below rests all of them
|
|
970
|
+
* on the current account: measured against a five-account Codex pool with
|
|
971
|
+
* distribution on, every session's `GET /models` landed on one account, 16
|
|
972
|
+
* requests against 1 apiece for its siblings.
|
|
973
|
+
*
|
|
974
|
+
* `_pickLeastLoaded` does not spread them. An untagged request never reaches
|
|
975
|
+
* `recordSession`, so it adds no session count, and a serial caller's
|
|
976
|
+
* in-flight is back to zero by the time the next one arrives — the tiebreak
|
|
977
|
+
* chain is level every time and answers with the same account. Hence a cursor
|
|
978
|
+
* of its own.
|
|
979
|
+
*
|
|
980
|
+
* That cursor is its own on purpose: `_setCurrent` is what sessions riding
|
|
981
|
+
* the shared one follow, and moving it from a catalog fetch would hand a
|
|
982
|
+
* running conversation to another account mid-flight and throw away the
|
|
983
|
+
* prompt cache it built there. An untagged request picks where it goes and
|
|
984
|
+
* changes nothing for anyone else.
|
|
985
|
+
*
|
|
986
|
+
* @param {Set<number>|null} exclude
|
|
987
|
+
* @param {string|null} model
|
|
988
|
+
* @param {string|null} advisorModel
|
|
989
|
+
* @returns {Record<string, any>|null}
|
|
990
|
+
*/
|
|
991
|
+
_selectUntagged(exclude, model, advisorModel) {
|
|
992
|
+
const candidates = this._bandedCandidates(exclude, model, advisorModel);
|
|
993
|
+
if (candidates.length === 0) return null;
|
|
994
|
+
const cursor = this._untaggedCursor;
|
|
995
|
+
this._untaggedCursor = cursor + 1;
|
|
996
|
+
return candidates[cursor % candidates.length];
|
|
997
|
+
}
|
|
998
|
+
|
|
947
999
|
_selectForSession(sessionId, exclude, model, advisorModel) {
|
|
948
1000
|
// The pin is per governing bucket, and this request is bound by the
|
|
949
1001
|
// EXECUTOR's: one request goes to one account, so the executor's affinity is
|
|
@@ -1680,7 +1732,7 @@ export class AccountManager {
|
|
|
1680
1732
|
account.status = 'active';
|
|
1681
1733
|
account.rateLimitedUntil = null;
|
|
1682
1734
|
account.throttledAt = null;
|
|
1683
|
-
console.log(`[TeamClaude] Account "${account.name}" rate limit expired, marking active`);
|
|
1735
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" rate limit expired, marking active`);
|
|
1684
1736
|
}
|
|
1685
1737
|
|
|
1686
1738
|
if (account.status === 'exhausted') return 'exhausted';
|
|
@@ -2177,6 +2229,12 @@ export class AccountManager {
|
|
|
2177
2229
|
* `_isAvailable` excludes accounts at or above the switch threshold. Shared by
|
|
2178
2230
|
* both selection loops so they cannot disagree on the candidate set.
|
|
2179
2231
|
*/
|
|
2232
|
+
/**
|
|
2233
|
+
* @param {Set<number>|null} [exclude]
|
|
2234
|
+
* @param {string|null} [model]
|
|
2235
|
+
* @param {string|null} [advisorModel]
|
|
2236
|
+
* @returns {Array<Record<string, any>>}
|
|
2237
|
+
*/
|
|
2180
2238
|
_bandedCandidates(exclude = null, model = null, advisorModel = null) {
|
|
2181
2239
|
return this._topPressureBand(
|
|
2182
2240
|
this.accounts.filter(a => !exclude?.has(a.index) && this._isAvailable(a, model, advisorModel)),
|
|
@@ -2547,7 +2605,7 @@ export class AccountManager {
|
|
|
2547
2605
|
const now = Date.now();
|
|
2548
2606
|
if (now < (this._rolloverHeldLogAt.get(key) || 0)) return;
|
|
2549
2607
|
this._rolloverHeldLogAt.set(key, now + 60_000);
|
|
2550
|
-
console.log(`[TeamClaude] Account "${account.name}" rolled over its ${window} window ${this._heldRolloverReason(reason)}`);
|
|
2608
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" rolled over its ${window} window ${this._heldRolloverReason(reason)}`);
|
|
2551
2609
|
}
|
|
2552
2610
|
|
|
2553
2611
|
/**
|
|
@@ -2699,14 +2757,27 @@ export class AccountManager {
|
|
|
2699
2757
|
}
|
|
2700
2758
|
|
|
2701
2759
|
/** 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
|
|
2760
|
+
* with a live eligibility flag for a representative model of the route and the
|
|
2761
|
+
* provider that will serve it.
|
|
2762
|
+
*
|
|
2763
|
+
* A route that NAMES its accounts is shown in full, whatever provider each one
|
|
2764
|
+
* belongs to. One route legitimately spans two: a translating sidecar reached
|
|
2765
|
+
* on the Anthropic wire, plus the subscription pool its own back leg re-enters
|
|
2766
|
+
* on the provider's path. Both hops are that route's traffic. Filtering the
|
|
2767
|
+
* view by a single provider hid the second set completely — the route looked
|
|
2768
|
+
* like it listed one account, and a newly added subscription that nobody had
|
|
2769
|
+
* added to the list was invisible rather than merely idle, which is the exact
|
|
2770
|
+
* shape of the diagnosis this view exists to prevent.
|
|
2771
|
+
*
|
|
2772
|
+
* A route that lists NOBODY is different: it constrains models, not accounts,
|
|
2773
|
+
* so only the asking provider's own pool can serve it and the partition still
|
|
2774
|
+
* applies. */
|
|
2703
2775
|
_routeAccountsView(route, provider = DEFAULT_PROVIDER) {
|
|
2704
2776
|
const sample = sampleModelFor(route);
|
|
2705
|
-
const
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
return
|
|
2709
|
-
.map(a => ({ name: a.name, eligible: this._isAvailable(a, sample) }));
|
|
2777
|
+
const listed = route.accounts.length
|
|
2778
|
+
? this.accounts.filter(a => route.accounts.includes(a.name) || route.accounts.includes(String(a.index)))
|
|
2779
|
+
: this.accounts.filter(a => !this._excludeOtherProviders(null, provider)?.has(a.index));
|
|
2780
|
+
return listed.map(a => ({ name: a.name, provider: providerOf(a), eligible: this._isAvailable(a, sample) }));
|
|
2710
2781
|
}
|
|
2711
2782
|
|
|
2712
2783
|
/** A representative model id for a route name (configured or auto fable/sonnet),
|
|
@@ -2785,7 +2856,7 @@ export class AccountManager {
|
|
|
2785
2856
|
|
|
2786
2857
|
// Clear expired unified quotas
|
|
2787
2858
|
if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
|
|
2788
|
-
console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
|
|
2859
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" session quota reset`);
|
|
2789
2860
|
// Recorded on the account, not just returned. _clearExpiredQuotas is
|
|
2790
2861
|
// reached from two directions — refreshExpiredQuotas on the request path,
|
|
2791
2862
|
// which runs the session-reset switch rule, and _isNearQuota via
|
|
@@ -2805,7 +2876,7 @@ export class AccountManager {
|
|
|
2805
2876
|
session = true;
|
|
2806
2877
|
}
|
|
2807
2878
|
if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
|
|
2808
|
-
console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
|
|
2879
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" weekly quota reset`);
|
|
2809
2880
|
q.unified7d = null;
|
|
2810
2881
|
q.unified7dReset = null;
|
|
2811
2882
|
q.unifiedStatus = null;
|
|
@@ -2849,7 +2920,7 @@ export class AccountManager {
|
|
|
2849
2920
|
// so a reading is never discarded before it has had a window to prove out.
|
|
2850
2921
|
if (!q[seenField]) { q[seenField] = now; continue; }
|
|
2851
2922
|
if (now < q[seenField] + this.familyStaleMs) continue;
|
|
2852
|
-
console.log(`[TeamClaude] Account "${account.name}" ${label} weekly reading is stale — revalidating on the next ${label} request`);
|
|
2923
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" ${label} weekly reading is stale — revalidating on the next ${label} request`);
|
|
2853
2924
|
q[key] = null;
|
|
2854
2925
|
q[`${key}Reset`] = null;
|
|
2855
2926
|
q[seenField] = null;
|
|
@@ -3284,7 +3355,7 @@ export class AccountManager {
|
|
|
3284
3355
|
if (account.probing && account.quota.unified7dReset != null) {
|
|
3285
3356
|
account.probing = false;
|
|
3286
3357
|
account.requalify = true;
|
|
3287
|
-
console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
|
|
3358
|
+
console.log(`[TeamClaude] Learned weekly quota for "${safeLine(account.name, 64)}", re-evaluating selection`);
|
|
3288
3359
|
}
|
|
3289
3360
|
|
|
3290
3361
|
this._observeBurnRate(account, observed);
|
|
@@ -3294,7 +3365,7 @@ export class AccountManager {
|
|
|
3294
3365
|
|
|
3295
3366
|
if (this._isNearQuota(account)) {
|
|
3296
3367
|
const pct = account.quota.unified7d != null ? Math.round(account.quota.unified7d * 100) : null;
|
|
3297
|
-
console.log(`[TeamClaude] "${account.name}" near weekly quota${pct == null ? '' : ` (${pct}%)`}`);
|
|
3368
|
+
console.log(`[TeamClaude] "${safeLine(account.name, 64)}" near weekly quota${pct == null ? '' : ` (${pct}%)`}`);
|
|
3298
3369
|
}
|
|
3299
3370
|
}
|
|
3300
3371
|
|
|
@@ -3351,7 +3422,7 @@ export class AccountManager {
|
|
|
3351
3422
|
if (account.probing && account.quota.unified7dReset != null) {
|
|
3352
3423
|
account.probing = false;
|
|
3353
3424
|
account.requalify = true;
|
|
3354
|
-
console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
|
|
3425
|
+
console.log(`[TeamClaude] Learned weekly quota for "${safeLine(account.name, 64)}", re-evaluating selection`);
|
|
3355
3426
|
}
|
|
3356
3427
|
|
|
3357
3428
|
// `unified-status` is upstream's verdict on THIS response. A family-cap 429
|
|
@@ -3384,14 +3455,26 @@ export class AccountManager {
|
|
|
3384
3455
|
// display, projection and switch-threshold logic apply unchanged. A window
|
|
3385
3456
|
// with no length is a bucket the plan does not have, not one at 0% used.
|
|
3386
3457
|
// used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3458
|
+
//
|
|
3459
|
+
// Unless this account is a CONDUIT: a local proxy whose own back leg draws
|
|
3460
|
+
// on the Codex accounts in this same fleet. Then the numbers it forwards
|
|
3461
|
+
// belong to whichever of them served, and filing them here makes the
|
|
3462
|
+
// conduit's bars a copy of the last one to answer. That is not merely a
|
|
3463
|
+
// wrong readout — the conduit is the only account its route can use on the
|
|
3464
|
+
// way in, so borrowing a spent account's number takes it below the switch
|
|
3465
|
+
// threshold and every request fails, while a sibling sits at 0%.
|
|
3466
|
+
// A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
|
|
3467
|
+
// its own login, the forwarded numbers are its own, and they still apply.
|
|
3468
|
+
if (!(isLocalUpstream(account) && this.accounts.some(a => providerOf(a) === 'codex'))) {
|
|
3469
|
+
for (const window of ['primary', 'secondary']) {
|
|
3470
|
+
const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
|
|
3471
|
+
const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
|
|
3472
|
+
if (isNaN(used) || !(minutes > 0)) continue;
|
|
3473
|
+
const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
|
|
3474
|
+
const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
|
|
3475
|
+
account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
|
|
3476
|
+
if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
|
|
3477
|
+
}
|
|
3395
3478
|
}
|
|
3396
3479
|
|
|
3397
3480
|
// Standard rate limits (API key accounts)
|
|
@@ -3427,7 +3510,7 @@ export class AccountManager {
|
|
|
3427
3510
|
: account.quota.tokensLimit
|
|
3428
3511
|
? ((1 - account.quota.tokensRemaining / account.quota.tokensLimit) * 100).toFixed(1)
|
|
3429
3512
|
: '?';
|
|
3430
|
-
console.log(`[TeamClaude] Account "${account.name}" at ${pct}% usage — will switch on next request`);
|
|
3513
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" at ${pct}% usage — will switch on next request`);
|
|
3431
3514
|
}
|
|
3432
3515
|
}
|
|
3433
3516
|
|
|
@@ -3520,7 +3603,7 @@ export class AccountManager {
|
|
|
3520
3603
|
// drop the dead-token guard too — otherwise the account would come back
|
|
3521
3604
|
// active but never attempt a refresh (see ensureTokenFresh).
|
|
3522
3605
|
account._deadRefreshToken = null;
|
|
3523
|
-
console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
|
|
3606
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" re-enabled — clearing error state`);
|
|
3524
3607
|
}
|
|
3525
3608
|
}
|
|
3526
3609
|
|
|
@@ -3585,7 +3668,7 @@ export class AccountManager {
|
|
|
3585
3668
|
}
|
|
3586
3669
|
// Worth a line: the account was refusing this family and is not any more.
|
|
3587
3670
|
if (wasSpent && !(q[key] != null && q[key] >= this.thresholdFor(key))) {
|
|
3588
|
-
console.log(`[TeamClaude] Account "${account.name}" ${label} weekly quota confirmed available by probe`);
|
|
3671
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" ${label} weekly quota confirmed available by probe`);
|
|
3589
3672
|
}
|
|
3590
3673
|
}
|
|
3591
3674
|
// Families beyond the two with dedicated fields. Replaced wholesale rather
|
|
@@ -3609,11 +3692,11 @@ export class AccountManager {
|
|
|
3609
3692
|
const was = q.spend;
|
|
3610
3693
|
q.spend = { ...usage.spend };
|
|
3611
3694
|
if (q.spend.enabled && !was?.enabled) {
|
|
3612
|
-
console.log(`[TeamClaude] Account "${account.name}" can bill real money past its plan limits (extra usage is enabled upstream)`);
|
|
3695
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" can bill real money past its plan limits (extra usage is enabled upstream)`);
|
|
3613
3696
|
}
|
|
3614
3697
|
const spentNow = (q.spend.usedMinor || 0) > 0;
|
|
3615
3698
|
if (spentNow && !((was?.usedMinor || 0) > 0)) {
|
|
3616
|
-
console.log(`[TeamClaude] Account "${account.name}" has started spending real money: ${formatMoney(q.spend)}`);
|
|
3699
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" has started spending real money: ${formatMoney(q.spend)}`);
|
|
3617
3700
|
}
|
|
3618
3701
|
}
|
|
3619
3702
|
|
|
@@ -3692,7 +3775,7 @@ export class AccountManager {
|
|
|
3692
3775
|
// after throttleProbeFloorMs from here, so a probe that 429s again pushes
|
|
3693
3776
|
// the next probe out by a full floor rather than hammering upstream.
|
|
3694
3777
|
account.throttledAt = Date.now();
|
|
3695
|
-
console.log(`[TeamClaude] Account "${account.name}" rate limited for ${retryAfterSeconds}s`);
|
|
3778
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" rate limited for ${retryAfterSeconds}s`);
|
|
3696
3779
|
}
|
|
3697
3780
|
|
|
3698
3781
|
/**
|
|
@@ -3706,7 +3789,7 @@ export class AccountManager {
|
|
|
3706
3789
|
account.status = 'active';
|
|
3707
3790
|
account.rateLimitedUntil = null;
|
|
3708
3791
|
account.throttledAt = null;
|
|
3709
|
-
console.log(`[TeamClaude] Account "${account.name}" revalidated — rate limit no longer applies, back in rotation`);
|
|
3792
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" revalidated — rate limit no longer applies, back in rotation`);
|
|
3710
3793
|
}
|
|
3711
3794
|
|
|
3712
3795
|
/**
|
|
@@ -3743,7 +3826,7 @@ export class AccountManager {
|
|
|
3743
3826
|
if (account._deadRefreshToken && account._deadRefreshToken === account.refreshToken) {
|
|
3744
3827
|
if (account.status !== 'error') {
|
|
3745
3828
|
account.status = 'error';
|
|
3746
|
-
console.error(`[TeamClaude] Account "${account.name}" still holds a rejected refresh token — run: teamclaude login`);
|
|
3829
|
+
console.error(`[TeamClaude] Account "${safeLine(account.name, 64)}" still holds a rejected refresh token — run: teamclaude login`);
|
|
3747
3830
|
}
|
|
3748
3831
|
return;
|
|
3749
3832
|
}
|
|
@@ -3769,7 +3852,7 @@ export class AccountManager {
|
|
|
3769
3852
|
if (account._refreshPromise) return account._refreshPromise;
|
|
3770
3853
|
|
|
3771
3854
|
account._refreshPromise = (async () => {
|
|
3772
|
-
console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
|
|
3855
|
+
console.log(`[TeamClaude] Refreshing token for account "${safeLine(account.name, 64)}"...`);
|
|
3773
3856
|
// The token we SEND, captured before the await. A config reload or
|
|
3774
3857
|
// `teamclaude import` (updateAccountTokens) can install newer tokens while
|
|
3775
3858
|
// the grant is in flight; reading `account.refreshToken` afterwards would
|
|
@@ -3786,7 +3869,7 @@ export class AccountManager {
|
|
|
3786
3869
|
? this._codexRefreshFn(sent)
|
|
3787
3870
|
: this._refreshFn(sent));
|
|
3788
3871
|
if (account.refreshToken !== sent) {
|
|
3789
|
-
console.log(`[TeamClaude] Discarding refresh result for account "${account.name}" — its tokens were replaced while the refresh was in flight`);
|
|
3872
|
+
console.log(`[TeamClaude] Discarding refresh result for account "${safeLine(account.name, 64)}" — its tokens were replaced while the refresh was in flight`);
|
|
3790
3873
|
return;
|
|
3791
3874
|
}
|
|
3792
3875
|
account.credential = newTokens.accessToken;
|
|
@@ -3794,10 +3877,10 @@ export class AccountManager {
|
|
|
3794
3877
|
account.expiresAt = newTokens.expiresAt;
|
|
3795
3878
|
account._lastRefreshAt = Date.now();
|
|
3796
3879
|
account._deadRefreshToken = null; // this token works; clear any stale guard
|
|
3797
|
-
console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
|
|
3880
|
+
console.log(`[TeamClaude] Token refreshed for account "${safeLine(account.name, 64)}"`);
|
|
3798
3881
|
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
3799
3882
|
} catch (err) {
|
|
3800
|
-
console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
|
|
3883
|
+
console.error(`[TeamClaude] Token refresh failed for "${safeLine(account.name, 64)}": ${err.message}`);
|
|
3801
3884
|
// Reserve 'error' (which drops the account from rotation until re-login)
|
|
3802
3885
|
// for a GENUINE auth rejection: the refresh token itself is no longer
|
|
3803
3886
|
// valid — revoked, or invalidated by an account/plan migration. A
|
|
@@ -3813,11 +3896,11 @@ export class AccountManager {
|
|
|
3813
3896
|
// a token imported mid-refresh stays untouched and gets its own try.
|
|
3814
3897
|
account._deadRefreshToken = sent;
|
|
3815
3898
|
if (account.refreshToken !== sent) {
|
|
3816
|
-
console.log(`[TeamClaude] Account "${account.name}" received new tokens while its old refresh token was being rejected — keeping the new ones`);
|
|
3899
|
+
console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" received new tokens while its old refresh token was being rejected — keeping the new ones`);
|
|
3817
3900
|
return;
|
|
3818
3901
|
}
|
|
3819
3902
|
account.status = 'error';
|
|
3820
|
-
console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
|
|
3903
|
+
console.error(`[TeamClaude] Account "${safeLine(account.name, 64)}" needs re-login (refresh token rejected) — run: teamclaude login`);
|
|
3821
3904
|
}
|
|
3822
3905
|
} finally {
|
|
3823
3906
|
account._refreshPromise = null;
|
|
@@ -3847,7 +3930,7 @@ export class AccountManager {
|
|
|
3847
3930
|
if (refreshToken) account.refreshToken = refreshToken;
|
|
3848
3931
|
account.expiresAt = expiresAt;
|
|
3849
3932
|
if (account.status === 'error') account.status = 'active';
|
|
3850
|
-
console.log(`[TeamClaude] Updated tokens for account "${account.name}"`);
|
|
3933
|
+
console.log(`[TeamClaude] Updated tokens for account "${safeLine(account.name, 64)}"`);
|
|
3851
3934
|
this._onTokenRefresh?.(accountIndex, {
|
|
3852
3935
|
accessToken,
|
|
3853
3936
|
refreshToken: account.refreshToken,
|
package/src/codex-quota.js
CHANGED
|
@@ -67,6 +67,12 @@ function collectFamilies(headers) {
|
|
|
67
67
|
return families;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* One window reading: utilization as a 0-1 fraction, reset as ms epoch or null.
|
|
72
|
+
*
|
|
73
|
+
* @typedef {{utilization: number, resetAt: number|null}} QuotaWindow
|
|
74
|
+
*/
|
|
75
|
+
|
|
70
76
|
/**
|
|
71
77
|
* Turn one family's windows into `{ fiveHour, weekly }` readings, keyed by the
|
|
72
78
|
* window's own duration rather than its primary/secondary position.
|
|
@@ -74,8 +80,12 @@ function collectFamilies(headers) {
|
|
|
74
80
|
* A window with no `window-minutes`, a zero duration, or an unparseable
|
|
75
81
|
* utilization is dropped: a zeroed window is how this API says "not
|
|
76
82
|
* applicable", and treating that as 0% used would look like full headroom.
|
|
83
|
+
*
|
|
84
|
+
* @param {Record<string, {usedPercent?: number, windowMinutes?: number, resetAt?: number}>} windows
|
|
85
|
+
* @returns {{fiveHour?: QuotaWindow, weekly?: QuotaWindow}}
|
|
77
86
|
*/
|
|
78
87
|
function classify(windows) {
|
|
88
|
+
/** @type {{fiveHour?: QuotaWindow, weekly?: QuotaWindow}} */
|
|
79
89
|
const out = {};
|
|
80
90
|
for (const w of Object.values(windows)) {
|
|
81
91
|
const minutes = Number(w.windowMinutes);
|
|
@@ -122,12 +132,19 @@ export function parseCodexQuota(headers) {
|
|
|
122
132
|
if (account.weekly.resetAt) quota.unified7dReset = account.weekly.resetAt;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
// Model-scoped families. Their
|
|
126
|
-
// the
|
|
127
|
-
//
|
|
135
|
+
// Model-scoped families. Their weekly reading is the family bucket, carried
|
|
136
|
+
// alongside the name upstream gave it. Their 5-hour one is picked up below:
|
|
137
|
+
// on a subscription it is the only 5h this API ever states.
|
|
138
|
+
/** @type {QuotaWindow|null} */
|
|
139
|
+
let scopedFiveHour = null;
|
|
128
140
|
for (const fam of families.values()) {
|
|
129
141
|
if (!fam.slug) continue;
|
|
130
142
|
const scoped = classify(fam.windows);
|
|
143
|
+
// Tightest wins, so the reading is taken before the weekly guard below
|
|
144
|
+
// drops a family that states a 5h window and no weekly one.
|
|
145
|
+
if (scoped.fiveHour && (!scopedFiveHour || scoped.fiveHour.utilization > scopedFiveHour.utilization)) {
|
|
146
|
+
scopedFiveHour = scoped.fiveHour;
|
|
147
|
+
}
|
|
131
148
|
if (!scoped.weekly) continue;
|
|
132
149
|
(quota.modelBuckets ??= []).push({
|
|
133
150
|
slug: fam.slug,
|
|
@@ -137,6 +154,21 @@ export function parseCodexQuota(headers) {
|
|
|
137
154
|
});
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
// A subscription's account-wide family states no 5-hour window at all: it
|
|
158
|
+
// puts the 7-day one in `primary` and zeroes `secondary`, which classify()
|
|
159
|
+
// drops, correctly, because a zero-length window is how this API says "not
|
|
160
|
+
// applicable". The only 5h it states sits in a named family, and upstream
|
|
161
|
+
// returns the same one whatever model was asked for — it is the account's
|
|
162
|
+
// session window wearing a model's name. So fill the shared bucket from it
|
|
163
|
+
// when the account-wide family left it empty, and never overwrite a reading
|
|
164
|
+
// the account-wide family did give: a family bucket barring models it does
|
|
165
|
+
// not meter would be the one-way ratchet the weekly buckets take such care
|
|
166
|
+
// to avoid.
|
|
167
|
+
if (quota.unified5h == null && scopedFiveHour) {
|
|
168
|
+
quota.unified5h = scopedFiveHour.utilization;
|
|
169
|
+
if (scopedFiveHour.resetAt) quota.unified5hReset = scopedFiveHour.resetAt;
|
|
170
|
+
}
|
|
171
|
+
|
|
140
172
|
return quota;
|
|
141
173
|
}
|
|
142
174
|
|
package/src/codex-usage.js
CHANGED
|
@@ -34,6 +34,40 @@ function classify(rateLimit) {
|
|
|
34
34
|
return { fiveHour, sevenDay };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Name each extra limit from the entry itself.
|
|
39
|
+
*
|
|
40
|
+
* A live subscription sends `additional_rate_limits` as a LIST whose entries
|
|
41
|
+
* name themselves (`metered_feature`, `limit_name`); older readings used an
|
|
42
|
+
* object keyed by the feature. `Object.entries` over a list hands back array
|
|
43
|
+
* indices, so every bucket was filed as "0" and "1" — two accounts' Spark
|
|
44
|
+
* limits collided under one meaningless key, and the header path's name for
|
|
45
|
+
* the same bucket stacked beside it rather than replacing it.
|
|
46
|
+
*
|
|
47
|
+
* `metered_feature` is that header name with a `codex_` prefix (`codex_bengalfox`
|
|
48
|
+
* here is `x-codex-bengalfox-*` there), so stripping it makes the two paths
|
|
49
|
+
* agree on one key per bucket.
|
|
50
|
+
*
|
|
51
|
+
* @param {any} additional
|
|
52
|
+
* @returns {Array<{slug: string, name: string, rateLimit: any}>}
|
|
53
|
+
*/
|
|
54
|
+
function additionalLimits(additional) {
|
|
55
|
+
if (Array.isArray(additional)) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const entry of additional) {
|
|
58
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
59
|
+
const feature = typeof entry.metered_feature === 'string' ? entry.metered_feature.replace(/^codex_/, '') : '';
|
|
60
|
+
const label = typeof entry.limit_name === 'string' ? entry.limit_name : '';
|
|
61
|
+
const slug = feature || label;
|
|
62
|
+
if (!slug) continue;
|
|
63
|
+
out.push({ slug, name: label || slug, rateLimit: entry.rate_limit || entry });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
return Object.entries(additional || {})
|
|
68
|
+
.map(([key, value]) => ({ slug: key, name: key, rateLimit: value?.rate_limit || value }));
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
/**
|
|
38
72
|
* Convert the private `/wham/usage` response into TeamClaude quota fields.
|
|
39
73
|
*
|
|
@@ -43,19 +77,36 @@ export function normalizeCodexUsagePayload(data) {
|
|
|
43
77
|
const rateLimit = data?.rate_limit || data?.rate_limits;
|
|
44
78
|
const shared = classify(rateLimit);
|
|
45
79
|
const modelBuckets = [];
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
/** @type {{utilization: number, resetAt: number|null, seconds: number}|null} */
|
|
81
|
+
let extraFiveHour = null;
|
|
82
|
+
for (const { slug, name, rateLimit: extra } of additionalLimits(data?.additional_rate_limits)) {
|
|
83
|
+
const reading = classify(extra);
|
|
84
|
+
// Taken before the weekly guard below, so an extra limit stating a 5-hour
|
|
85
|
+
// window and no weekly one contributes its reading instead of being
|
|
86
|
+
// dropped whole. Tightest wins when several state one.
|
|
87
|
+
if (reading.fiveHour && (!extraFiveHour || reading.fiveHour.utilization > extraFiveHour.utilization)) {
|
|
88
|
+
extraFiveHour = reading.fiveHour;
|
|
89
|
+
}
|
|
48
90
|
if (reading.sevenDay) {
|
|
49
91
|
modelBuckets.push({
|
|
50
|
-
slug
|
|
92
|
+
slug,
|
|
51
93
|
name,
|
|
52
94
|
utilization: reading.sevenDay.utilization,
|
|
53
95
|
resetAt: reading.sevenDay.resetAt,
|
|
54
96
|
});
|
|
55
97
|
}
|
|
56
98
|
}
|
|
99
|
+
|
|
100
|
+
// The shared `rate_limit` on a subscription states a 7-day window and a null
|
|
101
|
+
// secondary, so it yields no 5-hour reading; the only one the payload states
|
|
102
|
+
// sits in an extra limit. Fall back to that so the probe learns a session
|
|
103
|
+
// window at all, and never let it replace a shared reading: an extra limit
|
|
104
|
+
// meters the models it names, and one barring the rest would be the one-way
|
|
105
|
+
// ratchet the weekly buckets are written to avoid.
|
|
106
|
+
const fiveHour = shared.fiveHour || extraFiveHour;
|
|
107
|
+
|
|
57
108
|
return {
|
|
58
|
-
fiveHour:
|
|
109
|
+
fiveHour: fiveHour && { utilization: fiveHour.utilization, resetAt: fiveHour.resetAt },
|
|
59
110
|
sevenDay: shared.sevenDay && { utilization: shared.sevenDay.utilization, resetAt: shared.sevenDay.resetAt },
|
|
60
111
|
modelBuckets,
|
|
61
112
|
planType: data?.plan_type || null,
|
package/src/config.js
CHANGED
|
@@ -101,6 +101,7 @@ export function createDefaultConfig() {
|
|
|
101
101
|
sessionTitles: { enabled: false, width: 18 },
|
|
102
102
|
projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
|
|
103
103
|
eventLogging: 'hide',
|
|
104
|
+
activityLog: null,
|
|
104
105
|
blockedModels: [],
|
|
105
106
|
accounts: [],
|
|
106
107
|
};
|