@rikcodes/teamclaude 1.1.13-rik.2 → 1.1.13-rik.5
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 +99 -2
- package/package.json +1 -1
- package/src/account-manager.js +196 -5
- package/src/claude-env.js +65 -1
- package/src/config.js +1 -1
- package/src/identity.js +24 -0
- package/src/index.js +72 -105
- package/src/json-format-stream.js +4 -1
- package/src/mitm.js +2 -2
- package/src/oauth.js +48 -4
- package/src/request-log.js +21 -3
- package/src/server.js +570 -60
- package/src/session-titles.js +47 -10
- package/src/sidecar.js +133 -0
- package/src/sync-accounts.js +121 -0
- package/src/tui.js +133 -43
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# TeamClaude
|
|
2
2
|
|
|
3
|
-
> **Fork notice (rikbrown).** This fork adds
|
|
3
|
+
> **Fork notice (rikbrown).** This fork adds four features and two reload fixes on top of
|
|
4
4
|
> [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude):
|
|
5
5
|
>
|
|
6
|
+
> - **[OpenAI models via a Codex sidecar](docs/openai.md)** (`sidecars` + `customModels`, opt-in):
|
|
7
|
+
> route `gpt-*` requests through a supervised local translating proxy to a ChatGPT subscription,
|
|
8
|
+
> under real model names — `/model gpt-5.6-sol` in the picker and typed, correct 272k context
|
|
9
|
+
> sizing, and dispatchable GPT subagents — while Claude traffic stays on the Claude accounts.
|
|
6
10
|
> - **[Soonest-weekly rotation](docs/routing.md#soonest-weekly-rotation)** (`soonestWeekly`, opt-in): rank
|
|
7
11
|
> equal-priority accounts by the weekly window that governs the requested model, continuously — preempt the
|
|
8
12
|
> current account when another resets more than `poolHours` sooner, and balance `distributeSessions` within
|
|
@@ -12,12 +16,14 @@
|
|
|
12
16
|
> bucket's consumption over a rolling window and tag every account row with whichever window binds
|
|
13
17
|
> first — `Ses TTL 38m` when it runs out before it resets, `Wk 22% unspent` when the reset arrives
|
|
14
18
|
> first and that much expires. A readout only: no selection code reads it.
|
|
15
|
-
> - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`,
|
|
19
|
+
> - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`, off by default):
|
|
16
20
|
> name each activity row after the Claude Code session that sent the request, reading the title
|
|
17
21
|
> `/rename` writes and the one Claude Code generates. A session with neither keeps its short id.
|
|
18
22
|
> - `soonestWeekly` and `distributeSessions` changes now apply on config reload; upstream applies
|
|
19
23
|
> `distributeSessions` only at startup.
|
|
20
24
|
>
|
|
25
|
+
> Setup and use of each: [Fork features](#fork-features).
|
|
26
|
+
>
|
|
21
27
|
> Published as [`@rikcodes/teamclaude`](https://www.npmjs.com/package/@rikcodes/teamclaude); self-update
|
|
22
28
|
> tracks that package, so installs of this fork can never be replaced by an upstream release.
|
|
23
29
|
>
|
|
@@ -68,6 +74,7 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
|
|
|
68
74
|
- Holds the request open until quota resets instead of returning 429 when every account is spent, so an unattended run finishes on its own (`holdSeconds`, off by default).
|
|
69
75
|
- Refreshes OAuth tokens before they expire and writes them back to config. Client refreshes pass through untouched.
|
|
70
76
|
- Takes any Anthropic-compatible API (DeepSeek, GLM) as a low-priority fallback for when the Claude accounts are done.
|
|
77
|
+
- 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).
|
|
71
78
|
- No dependencies. Node built-ins only.
|
|
72
79
|
|
|
73
80
|
## Everyday commands
|
|
@@ -99,6 +106,95 @@ Every field, plus environment variables and network tuning: [docs/configuration.
|
|
|
99
106
|
|
|
100
107
|
Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
|
|
101
108
|
|
|
109
|
+
## Fork features
|
|
110
|
+
|
|
111
|
+
How to set up and use the features in this fork. Everything else in this README is upstream behaviour.
|
|
112
|
+
|
|
113
|
+
### OpenAI models via a Codex sidecar
|
|
114
|
+
|
|
115
|
+
A Claude Code session can use OpenAI models alongside the Claude accounts. They are billed to a ChatGPT Plus/Pro subscription and use their real model names in the same session. TeamClaude does not translate the wire format itself. A local **sidecar** does this: [raine/claude-code-proxy](https://github.com/raine/claude-code-proxy) speaks `/v1/messages` on the front and the Codex Responses API on the back. TeamClaude starts and supervises this process. It sends every `gpt-*` request to the sidecar and keeps every other request on the Claude accounts.
|
|
116
|
+
|
|
117
|
+
**1. Install the sidecar and log it into your ChatGPT account** (one time):
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
brew install raine/claude-code-proxy/claude-code-proxy
|
|
121
|
+
claude-code-proxy codex auth login
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**2. Connect it** — add four pieces to `~/.config/teamclaude.json`:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"sidecars": [
|
|
129
|
+
{ "name": "codex", "command": ["claude-code-proxy", "serve", "--no-monitor", "--port", "18765"] }
|
|
130
|
+
],
|
|
131
|
+
"accounts": [
|
|
132
|
+
{ "name": "codex", "type": "oauth", "accessToken": "unused-local-sidecar",
|
|
133
|
+
"upstream": "http://127.0.0.1:18765", "priority": 100 }
|
|
134
|
+
],
|
|
135
|
+
"routes": [
|
|
136
|
+
{ "name": "codex", "match": ["gpt-*"], "accounts": ["codex"] },
|
|
137
|
+
{ "name": "anthropic", "match": ["*"], "accounts": ["your-claude-account", "..."] }
|
|
138
|
+
],
|
|
139
|
+
"customModels": [
|
|
140
|
+
{ "model": "gpt-5.6-sol", "label": "GPT-5.6 Sol", "contextTokens": 272000 },
|
|
141
|
+
{ "model": "gpt-5.6-terra", "label": "GPT-5.6 Terra", "contextTokens": 272000 },
|
|
142
|
+
{ "model": "gpt-5.6-luna", "label": "GPT-5.6 Luna", "contextTokens": 272000 }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- `sidecars` — the process that TeamClaude owns. It starts with the server, restarts with backoff after a crash, and stops on shutdown. Its pid, restart count and latest stderr lines are in `teamclaude status --json` under `sidecars`.
|
|
148
|
+
- `accounts` — the sidecar as a [third-party backend account](docs/accounts.md#third-party-backend-accounts). The token is a placeholder because the sidecar uses its own Codex login for authentication. `priority: 100` is the convention for third-party backends. The routes determine what reaches it.
|
|
149
|
+
- `routes` — sends `gpt-*` to the sidecar. **Keep the catch-all `*` route.** Without it, the sidecar joins the exhaustion-fallback pool. A spent Claude fleet would then silently send Claude-model requests to the sidecar, which maps `claude-*` names onto GPT models. With the route, a request can reach a GPT model only when it asks for one by name.
|
|
150
|
+
- `customModels` — the rows that make the models visible to Claude Code (next section).
|
|
151
|
+
|
|
152
|
+
**3. Restart the server.** `teamclaude status --json` should show the sidecar as `running`. If it enters a crash loop, `stderrTail` explains why — the usual cause is that the sidecar is not logged in.
|
|
153
|
+
|
|
154
|
+
#### How models get into Claude Code
|
|
155
|
+
|
|
156
|
+
Claude Code offers only models that it knows, and it does not know `gpt-*`. `customModels` closes this gap. Each row contains a model id that the proxy can serve, an optional picker label and description, and the model's context window.
|
|
157
|
+
|
|
158
|
+
At launch, `teamclaude run` — and the `claude` alias, which passes through `run` — gives these rows to Claude Code:
|
|
159
|
+
|
|
160
|
+
| Row field | Where it ends up |
|
|
161
|
+
| --- | --- |
|
|
162
|
+
| `model`, `label`, `description` | A `/model` picker row under the **real** model id (`--settings`), so `/model gpt-5.6-sol` works picked or typed |
|
|
163
|
+
| `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent |
|
|
164
|
+
| `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 |
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
**To add a model:**
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
2. Add a `customModels` row. Codex publishes the window for each model as `context_window` in `~/.codex/models_cache.json`; copy it to `contextTokens`.
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
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, and use **one** ChatGPT subscription for each person. Pooling several subscriptions is the pattern that OpenAI's fraud systems target ([terms of service](docs/openai.md#terms-of-service)).
|
|
177
|
+
|
|
178
|
+
Full details: [docs/openai.md](docs/openai.md).
|
|
179
|
+
|
|
180
|
+
### Soonest-weekly rotation
|
|
181
|
+
|
|
182
|
+
Default selection is sticky. It re-ranks only when the current account is exhausted, so a weekly window can expire with unused quota. `soonestWeekly` re-ranks continuously. Among equal-priority accounts, the one whose governing weekly window resets soonest is used first. It preempts the current account when another account resets more than `poolHours` sooner.
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
"soonestWeekly": { "enabled": true, "poolHours": 12 }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`distributeSessions` works with this setting. New sessions balance across that pool instead of across all equal-priority accounts. Both settings take effect when the configuration reloads (upstream applies `distributeSessions` only at startup). Details: [Routing](docs/routing.md#soonest-weekly-rotation).
|
|
189
|
+
|
|
190
|
+
### Burn-rate projection
|
|
191
|
+
|
|
192
|
+
This feature is on by default. Each account row shows which window binds first: `Ses TTL 38m` when the session bucket runs out before it resets, or `Wk 22% unspent` when the weekly reset arrives first and that amount of quota expires. This is a readout only; no selection code reads it. Tune or disable it with `projection: { enabled, windowMinutes, wasteFloor }`. Details: [Quota](docs/quota.md#burn-rate-projection).
|
|
193
|
+
|
|
194
|
+
### Session titles
|
|
195
|
+
|
|
196
|
+
This feature is off by default. Activity rows use the name of the Claude Code session that sent the request. They use the title written by `/rename` or the title that Claude Code generates; a session with neither keeps its short id. Toggle the feature from the settings screen (**g** → Session titles) or with `sessionTitles: { enabled, width, projectsDir }`. Details: [Usage](docs/usage.md#session-titles-in-the-activity-log).
|
|
197
|
+
|
|
102
198
|
## Documentation
|
|
103
199
|
|
|
104
200
|
| Page | Contents |
|
|
@@ -107,6 +203,7 @@ Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
|
|
|
107
203
|
| [Usage](docs/usage.md) | Server and TUI, running Claude Code, shell alias, command reference, logging |
|
|
108
204
|
| [Routing](docs/routing.md) | Rotation, the two kinds of 429, storm control, model routes, session spreading, pinning, prompt cache |
|
|
109
205
|
| [Quota](docs/quota.md) | Quota probe, keep-warm, holding on exhaustion |
|
|
206
|
+
| [OpenAI models](docs/openai.md) | Codex sidecar setup, custom model registration, GPT subagents, limitations |
|
|
110
207
|
| [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
|
|
111
208
|
| [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
|
|
112
209
|
| [Compliance](docs/compliance.md) | Terms of service notes |
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -19,9 +19,35 @@ const FORCED_REFRESH_FLOOR_MS = 10_000;
|
|
|
19
19
|
const PERSISTED_QUOTA_FIELDS = [
|
|
20
20
|
'unified5h', 'unified7d', 'unified7dSonnet', 'unified7dFable',
|
|
21
21
|
'unified5hReset', 'unified7dReset', 'unified7dSonnetReset', 'unified7dFableReset', 'unifiedStatus',
|
|
22
|
+
'unified7dSonnetSeenAt', 'unified7dFableSeenAt',
|
|
22
23
|
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
23
24
|
];
|
|
24
25
|
|
|
26
|
+
// The family (Fable/Sonnet) weekly buckets and the field holding when each was
|
|
27
|
+
// last confirmed by upstream. See _clearExpiredQuotas: a SPENT family reading is
|
|
28
|
+
// only trusted while it is fresh, because nothing but a request of that family
|
|
29
|
+
// can refresh it.
|
|
30
|
+
const FAMILY_WEEKLY_BUCKETS = [
|
|
31
|
+
{ key: 'unified7dFable', label: 'Fable' },
|
|
32
|
+
{ key: 'unified7dSonnet', label: 'Sonnet' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Longest Codex window still treated as a session bucket. The two lengths seen
|
|
36
|
+
// in practice are 300 minutes (5h) and 10080 (weekly), so a day sits safely
|
|
37
|
+
// between them.
|
|
38
|
+
const CODEX_WEEKLY_MIN_MINUTES = 1440;
|
|
39
|
+
|
|
40
|
+
// A Codex `*-reset-at` header as a ms timestamp. The wire format isn't pinned
|
|
41
|
+
// down (the sidecar forwards it opaquely), so accept epoch seconds, epoch ms,
|
|
42
|
+
// or an ISO-8601 date; anything else is null.
|
|
43
|
+
function parseResetAt(value) {
|
|
44
|
+
if (value == null || value === '') return null;
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (Number.isFinite(n)) return n > 1e12 ? n : n * 1000;
|
|
47
|
+
const parsed = Date.parse(value);
|
|
48
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
function emptyQuota() {
|
|
26
52
|
return {
|
|
27
53
|
// Standard API rate limits (API key accounts)
|
|
@@ -38,6 +64,11 @@ function emptyQuota() {
|
|
|
38
64
|
unified7dReset: null, // ms timestamp
|
|
39
65
|
unified7dSonnetReset: null, // ms timestamp
|
|
40
66
|
unified7dFableReset: null, // ms timestamp
|
|
67
|
+
// When each family bucket was last confirmed by upstream (ms timestamp).
|
|
68
|
+
// Only these two buckets need it: they are the ones a spent reading can seal
|
|
69
|
+
// itself into, since selection stops sending the family that would refresh them.
|
|
70
|
+
unified7dSonnetSeenAt: null,
|
|
71
|
+
unified7dFableSeenAt: null,
|
|
41
72
|
unifiedStatus: null, // allowed | allowed_warning | rejected
|
|
42
73
|
resetsAt: null,
|
|
43
74
|
};
|
|
@@ -89,6 +120,9 @@ function makeAccount(acct, index) {
|
|
|
89
120
|
// (post-401) refreshes so a burst of stale in-flight requests can't rotate
|
|
90
121
|
// the refresh-token family once per request — see ensureTokenFresh.
|
|
91
122
|
_lastRefreshAt: null,
|
|
123
|
+
// The refresh token upstream last rejected as invalid, if it is still the
|
|
124
|
+
// one we hold — see the dead-token guard in ensureTokenFresh.
|
|
125
|
+
_deadRefreshToken: null,
|
|
92
126
|
};
|
|
93
127
|
}
|
|
94
128
|
|
|
@@ -109,7 +143,7 @@ function sampleModelFor(route) {
|
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
export class AccountManager {
|
|
112
|
-
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
|
|
146
|
+
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, familyStaleMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
|
|
113
147
|
// How long a just-minted token is trusted against a forced refresh.
|
|
114
148
|
this._forcedRefreshFloorMs = forcedRefreshFloorMs;
|
|
115
149
|
// Injectable for tests (mirrors Prober's probeFn); defaults to the real
|
|
@@ -129,6 +163,13 @@ export class AccountManager {
|
|
|
129
163
|
// bias selection for a route's models and reset on restart. A pinned account
|
|
130
164
|
// that becomes ineligible is skipped — routing falls back to best-available.
|
|
131
165
|
this.routePins = new Map();
|
|
166
|
+
// Selection cursor per route (routeName → account index; '' when no route
|
|
167
|
+
// matches). A single global cursor reads traffic that alternates between
|
|
168
|
+
// routes as a rotation: the cursor sits on the other route's account, that
|
|
169
|
+
// account fails this model's route check, and selection "switches" away from
|
|
170
|
+
// it. Each such switch arms the ramp below, so steady interleaved traffic
|
|
171
|
+
// holds both accounts at the ramp floor while nothing has failed over.
|
|
172
|
+
this.routeCursors = new Map();
|
|
132
173
|
this.switchThreshold = switchThreshold;
|
|
133
174
|
this.setRoutes(routes);
|
|
134
175
|
this.setSoonestWeekly(soonestWeekly);
|
|
@@ -159,6 +200,13 @@ export class AccountManager {
|
|
|
159
200
|
// retry-after, short enough that a stale hold cannot pin the fleet.
|
|
160
201
|
this.throttleProbeFloorMs = throttleProbeFloorMs
|
|
161
202
|
?? (Number(process.env.TEAMCLAUDE_THROTTLE_PROBE_FLOOR_MS) || 60_000);
|
|
203
|
+
// How long a SPENT family (Fable/Sonnet) weekly reading is trusted before it
|
|
204
|
+
// is cleared for revalidation (see _clearExpiredQuotas). Long enough that a
|
|
205
|
+
// genuinely spent bucket costs at most one rejected request per account per
|
|
206
|
+
// window, short enough that a stale reading cannot lock a family out for the
|
|
207
|
+
// rest of the weekly window.
|
|
208
|
+
this.familyStaleMs = familyStaleMs
|
|
209
|
+
?? (Number(process.env.TEAMCLAUDE_FAMILY_STALE_MS) || 30 * 60_000);
|
|
162
210
|
}
|
|
163
211
|
|
|
164
212
|
/** Start (or restart) the ramp window for an account that just became current,
|
|
@@ -245,6 +293,16 @@ export class AccountManager {
|
|
|
245
293
|
* request keeps flowing (upstream then fails just the advisor call).
|
|
246
294
|
*/
|
|
247
295
|
getActiveAccount(exclude = null, model = null, advisorModel = null, sessionId = null) {
|
|
296
|
+
const account = this._pickActiveAccount(exclude, model, advisorModel, sessionId);
|
|
297
|
+
// Record where this route now sits, whatever path chose it — the steady-state
|
|
298
|
+
// path returns the account the cursor already names and never reaches the
|
|
299
|
+
// rotation code, so recording there alone would leave the cursor unset and
|
|
300
|
+
// the next real failover unpaced.
|
|
301
|
+
if (account) this.routeCursors.set(this._cursorKey(model), account.index);
|
|
302
|
+
return account;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_pickActiveAccount(exclude, model, advisorModel, sessionId) {
|
|
248
306
|
// Clear expired quotas across all accounts and switch proactively if a
|
|
249
307
|
// session reset made a sooner-expiring account the better choice. This runs
|
|
250
308
|
// on every request so the behaviour holds without the TUI render loop.
|
|
@@ -757,6 +815,29 @@ export class AccountManager {
|
|
|
757
815
|
if (name !== 'fable' && name !== 'sonnet' && !names.has(name)) this.routePins.delete(name);
|
|
758
816
|
}
|
|
759
817
|
}
|
|
818
|
+
// A reload can rename or drop a route, stranding its cursor under a key
|
|
819
|
+
// nothing resolves to. Clearing them costs one extra best-available walk per
|
|
820
|
+
// route and keeps no state that outlives the table it belonged to.
|
|
821
|
+
this.routeCursors?.clear();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Cursor key for a model: its route's name, or '' when no route matches. */
|
|
825
|
+
_cursorKey(model) {
|
|
826
|
+
return this._routeForModel(model)?.name || '';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** The account this route was serving from before the current selection, or
|
|
830
|
+
* null when it has none — used to tell a rotation from ordinary routing.
|
|
831
|
+
*
|
|
832
|
+
* Before a route has its own cursor, the global one stands in, but only when
|
|
833
|
+
* it names an account the route could have used: a cursor left on another
|
|
834
|
+
* route's account was never this route's position, so moving off it is not a
|
|
835
|
+
* rotation. */
|
|
836
|
+
_previousCursor(model) {
|
|
837
|
+
const recorded = this.routeCursors.get(this._cursorKey(model));
|
|
838
|
+
if (recorded != null) return recorded;
|
|
839
|
+
const current = this.accounts[this.currentIndex];
|
|
840
|
+
return current && this._routeAllows(current, model) ? current.index : null;
|
|
760
841
|
}
|
|
761
842
|
|
|
762
843
|
/** The first configured route whose globs match `model`, or null. */
|
|
@@ -947,11 +1028,44 @@ export class AccountManager {
|
|
|
947
1028
|
if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
|
|
948
1029
|
q.unified7dSonnet = null;
|
|
949
1030
|
q.unified7dSonnetReset = null;
|
|
1031
|
+
q.unified7dSonnetSeenAt = null;
|
|
950
1032
|
changed = true;
|
|
951
1033
|
}
|
|
952
1034
|
if (q.unified7dFable != null && q.unified7dFableReset && now >= q.unified7dFableReset) {
|
|
953
1035
|
q.unified7dFable = null;
|
|
954
1036
|
q.unified7dFableReset = null;
|
|
1037
|
+
q.unified7dFableSeenAt = null;
|
|
1038
|
+
changed = true;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// A family bucket is refreshed ONLY by upstream evidence for that family:
|
|
1042
|
+
// the `7d_oi` headers ride on Fable responses (they are absent from every
|
|
1043
|
+
// other model's response), and the Sonnet bucket comes from the usage
|
|
1044
|
+
// endpoint — an opt-in probe that is off by default. So once such a bucket
|
|
1045
|
+
// reads spent, selection stops sending that family to the account, which is
|
|
1046
|
+
// also the only thing that could have corrected the reading: it seals itself
|
|
1047
|
+
// in until its cached reset passes, up to a week of lockout on an account
|
|
1048
|
+
// whose real family quota reset long ago (issue #167).
|
|
1049
|
+
//
|
|
1050
|
+
// A spent family reading is therefore trusted only while it is fresh. Past
|
|
1051
|
+
// the staleness floor it is cleared, the family falls back to the shared
|
|
1052
|
+
// weekly bucket, and the next request of that family re-establishes the
|
|
1053
|
+
// truth from real headers — a 429 re-arms the gate with a fresh reading and
|
|
1054
|
+
// a fresh timestamp, so a genuinely spent bucket costs one rejected request
|
|
1055
|
+
// per account per window and no more. A reading with headroom is left alone:
|
|
1056
|
+
// it gates nothing, so it cannot seal anything in.
|
|
1057
|
+
for (const { key, label } of FAMILY_WEEKLY_BUCKETS) {
|
|
1058
|
+
if (q[key] == null || q[key] < this.switchThreshold) continue;
|
|
1059
|
+
const seenField = `${key}SeenAt`;
|
|
1060
|
+
// Unknown age (restored from an older state file, or set by a path that
|
|
1061
|
+
// predates the stamp): start the clock now rather than clearing at once,
|
|
1062
|
+
// so a reading is never discarded before it has had a window to prove out.
|
|
1063
|
+
if (!q[seenField]) { q[seenField] = now; continue; }
|
|
1064
|
+
if (now < q[seenField] + this.familyStaleMs) continue;
|
|
1065
|
+
console.log(`[TeamClaude] Account "${account.name}" ${label} weekly reading is stale — revalidating on the next ${label} request`);
|
|
1066
|
+
q[key] = null;
|
|
1067
|
+
q[`${key}Reset`] = null;
|
|
1068
|
+
q[seenField] = null;
|
|
955
1069
|
changed = true;
|
|
956
1070
|
}
|
|
957
1071
|
|
|
@@ -1116,7 +1230,8 @@ export class AccountManager {
|
|
|
1116
1230
|
_selectNext(exclude = null, model = null, advisorModel = null) {
|
|
1117
1231
|
const best = this._pickBestAvailable(exclude, model, advisorModel);
|
|
1118
1232
|
if (best) {
|
|
1119
|
-
const
|
|
1233
|
+
const previous = this._previousCursor(model);
|
|
1234
|
+
const switched = previous != null && previous !== best.index;
|
|
1120
1235
|
this.currentIndex = best.index;
|
|
1121
1236
|
// If we switched to an account whose weekly quota is still unknown, flag
|
|
1122
1237
|
// it so we re-evaluate once that quota is learned (see updateQuota).
|
|
@@ -1188,8 +1303,14 @@ export class AccountManager {
|
|
|
1188
1303
|
// overage included"). On current subscription plans this is the Fable weekly
|
|
1189
1304
|
// limit (it correlates with the usage endpoint's Fable-scoped weekly bucket).
|
|
1190
1305
|
// Utilization here is already a 0-1 fraction (can exceed 1 when in overage).
|
|
1306
|
+
// These headers ride on Fable responses only, so stamp when the reading was
|
|
1307
|
+
// taken: that timestamp is what lets a spent reading be revalidated instead
|
|
1308
|
+
// of sealing the account out of the family forever (see _clearExpiredQuotas).
|
|
1191
1309
|
const u7dOi = parseFloat(headers['anthropic-ratelimit-unified-7d_oi-utilization']);
|
|
1192
|
-
if (!isNaN(u7dOi))
|
|
1310
|
+
if (!isNaN(u7dOi)) {
|
|
1311
|
+
account.quota.unified7dFable = u7dOi;
|
|
1312
|
+
account.quota.unified7dFableSeenAt = Date.now();
|
|
1313
|
+
}
|
|
1193
1314
|
const r7dOi = headers['anthropic-ratelimit-unified-7d_oi-reset'];
|
|
1194
1315
|
if (r7dOi) account.quota.unified7dFableReset = parseInt(r7dOi, 10) * 1000;
|
|
1195
1316
|
|
|
@@ -1204,6 +1325,25 @@ export class AccountManager {
|
|
|
1204
1325
|
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
1205
1326
|
if (uStatus) account.quota.unifiedStatus = uStatus;
|
|
1206
1327
|
|
|
1328
|
+
// OpenAI/Codex windows (`x-codex-*`, forwarded by a translating sidecar for
|
|
1329
|
+
// a ChatGPT-subscription account). Codex reports two windows, `primary` and
|
|
1330
|
+
// `secondary`, whose meaning comes from the declared length rather than the
|
|
1331
|
+
// position: a ChatGPT Pro plan reports its weekly limit as the primary one
|
|
1332
|
+
// and meters no secondary window at all. So each window is filed by length,
|
|
1333
|
+
// which lands it in the same 5h/weekly slots the rest of the code reads —
|
|
1334
|
+
// display, projection and switch-threshold logic apply unchanged. A window
|
|
1335
|
+
// with no length is a bucket the plan does not have, not one at 0% used.
|
|
1336
|
+
// used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
|
|
1337
|
+
for (const window of ['primary', 'secondary']) {
|
|
1338
|
+
const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
|
|
1339
|
+
const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
|
|
1340
|
+
if (isNaN(used) || !(minutes > 0)) continue;
|
|
1341
|
+
const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
|
|
1342
|
+
const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
|
|
1343
|
+
account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
|
|
1344
|
+
if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1207
1347
|
// Standard rate limits (API key accounts)
|
|
1208
1348
|
const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
|
|
1209
1349
|
const tokensRemaining = parseInt(headers['anthropic-ratelimit-tokens-remaining'], 10);
|
|
@@ -1258,6 +1398,10 @@ export class AccountManager {
|
|
|
1258
1398
|
if (!disabled && account.status === 'error') {
|
|
1259
1399
|
account.status = 'active';
|
|
1260
1400
|
account.rateLimitedUntil = null;
|
|
1401
|
+
// Operator escape hatch: re-enabling is an explicit "try this again", so
|
|
1402
|
+
// drop the dead-token guard too — otherwise the account would come back
|
|
1403
|
+
// active but never attempt a refresh (see ensureTokenFresh).
|
|
1404
|
+
account._deadRefreshToken = null;
|
|
1261
1405
|
console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
|
|
1262
1406
|
}
|
|
1263
1407
|
}
|
|
@@ -1280,12 +1424,23 @@ export class AccountManager {
|
|
|
1280
1424
|
if (usage.sevenDay.utilization != null) q.unified7d = usage.sevenDay.utilization;
|
|
1281
1425
|
if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
|
|
1282
1426
|
}
|
|
1427
|
+
// The family buckets carry a "last confirmed" stamp (see _clearExpiredQuotas).
|
|
1428
|
+
// A probe is upstream evidence just like a response header, so it refreshes
|
|
1429
|
+
// the stamp — this is the one path that can correct a spent family reading
|
|
1430
|
+
// without spending quota, which is why enabling the probe sidesteps the
|
|
1431
|
+
// staleness problem entirely.
|
|
1283
1432
|
if (usage.sevenDaySonnet) {
|
|
1284
|
-
if (usage.sevenDaySonnet.utilization != null)
|
|
1433
|
+
if (usage.sevenDaySonnet.utilization != null) {
|
|
1434
|
+
q.unified7dSonnet = usage.sevenDaySonnet.utilization;
|
|
1435
|
+
q.unified7dSonnetSeenAt = Date.now();
|
|
1436
|
+
}
|
|
1285
1437
|
if (usage.sevenDaySonnet.resetAt != null) q.unified7dSonnetReset = usage.sevenDaySonnet.resetAt;
|
|
1286
1438
|
}
|
|
1287
1439
|
if (usage.sevenDayFable) {
|
|
1288
|
-
if (usage.sevenDayFable.utilization != null)
|
|
1440
|
+
if (usage.sevenDayFable.utilization != null) {
|
|
1441
|
+
q.unified7dFable = usage.sevenDayFable.utilization;
|
|
1442
|
+
q.unified7dFableSeenAt = Date.now();
|
|
1443
|
+
}
|
|
1289
1444
|
if (usage.sevenDayFable.resetAt != null) q.unified7dFableReset = usage.sevenDayFable.resetAt;
|
|
1290
1445
|
}
|
|
1291
1446
|
|
|
@@ -1337,6 +1492,31 @@ export class AccountManager {
|
|
|
1337
1492
|
const account = this.accounts[accountIndex];
|
|
1338
1493
|
if (!account || account.type !== 'oauth' || !account.refreshToken) return;
|
|
1339
1494
|
|
|
1495
|
+
// Dead-token guard: a refresh token upstream already rejected (invalid_grant)
|
|
1496
|
+
// will be rejected every time, so retrying it only floods the OAuth endpoint
|
|
1497
|
+
// — observed live: 287 identical invalid_grant calls after two accounts' tokens
|
|
1498
|
+
// were invalidated (a `/login` elsewhere rotates the token and kills the copy
|
|
1499
|
+
// teamclaude holds). Paths that bypass availability checks keep calling this
|
|
1500
|
+
// (the quota prober refreshes every OAuth account regardless of status, and a
|
|
1501
|
+
// pinned request reaches here without _isAvailable), so marking the account
|
|
1502
|
+
// 'error' alone does not stop the retries. Keyed on the token VALUE, not the
|
|
1503
|
+
// status: the moment a DIFFERENT refresh token arrives (re-login, config
|
|
1504
|
+
// reload, updateAccountTokens) the guard lifts on its own.
|
|
1505
|
+
//
|
|
1506
|
+
// While it holds, the account must also READ as needing a re-login. A
|
|
1507
|
+
// re-import can hand updateAccountTokens a new access token alongside the
|
|
1508
|
+
// same dead refresh token; that path resets status to 'active', so without
|
|
1509
|
+
// this the access token's 401 would force a refresh that silently does
|
|
1510
|
+
// nothing here and the retry would relay the 401 to the client instead of
|
|
1511
|
+
// rotating to another account.
|
|
1512
|
+
if (account._deadRefreshToken && account._deadRefreshToken === account.refreshToken) {
|
|
1513
|
+
if (account.status !== 'error') {
|
|
1514
|
+
account.status = 'error';
|
|
1515
|
+
console.error(`[TeamClaude] Account "${account.name}" still holds a rejected refresh token — run: teamclaude login`);
|
|
1516
|
+
}
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1340
1520
|
if (!force && !isTokenExpiringSoon(account.expiresAt)) return;
|
|
1341
1521
|
|
|
1342
1522
|
// A forced refresh answers a 401, but 401s arrive in bursts: every request
|
|
@@ -1365,6 +1545,7 @@ export class AccountManager {
|
|
|
1365
1545
|
account.refreshToken = newTokens.refreshToken;
|
|
1366
1546
|
account.expiresAt = newTokens.expiresAt;
|
|
1367
1547
|
account._lastRefreshAt = Date.now();
|
|
1548
|
+
account._deadRefreshToken = null; // this token works; clear any stale guard
|
|
1368
1549
|
console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
|
|
1369
1550
|
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
1370
1551
|
} catch (err) {
|
|
@@ -1378,6 +1559,10 @@ export class AccountManager {
|
|
|
1378
1559
|
const isAuthRejection = err.status === 400 || err.status === 401 || err.status === 403;
|
|
1379
1560
|
if (isAuthRejection) {
|
|
1380
1561
|
account.status = 'error';
|
|
1562
|
+
// Remember WHICH token was rejected so we stop re-sending it (see the
|
|
1563
|
+
// dead-token guard above). A transient failure deliberately does not
|
|
1564
|
+
// arm this — that token may still be good.
|
|
1565
|
+
account._deadRefreshToken = account.refreshToken;
|
|
1381
1566
|
console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
|
|
1382
1567
|
}
|
|
1383
1568
|
} finally {
|
|
@@ -1441,6 +1626,12 @@ export class AccountManager {
|
|
|
1441
1626
|
if (idx === index) this.routePins.delete(name);
|
|
1442
1627
|
else if (idx > index) this.routePins.set(name, idx - 1);
|
|
1443
1628
|
}
|
|
1629
|
+
// Same for the selection cursors: a cursor on the removed account is dropped
|
|
1630
|
+
// so the route re-picks, and one above it follows the shift.
|
|
1631
|
+
for (const [name, idx] of [...this.routeCursors.entries()]) {
|
|
1632
|
+
if (idx === index) this.routeCursors.delete(name);
|
|
1633
|
+
else if (idx > index) this.routeCursors.set(name, idx - 1);
|
|
1634
|
+
}
|
|
1444
1635
|
}
|
|
1445
1636
|
|
|
1446
1637
|
/**
|
package/src/claude-env.js
CHANGED
|
@@ -30,7 +30,66 @@ export function encodePinComponent(s) {
|
|
|
30
30
|
// `/tc-acct/` prefix. TC_ACCT itself is then unset, so the pin does not leak
|
|
31
31
|
// into claude or anything it spawns — same reasoning as `run` deleting it from
|
|
32
32
|
// the child environment.
|
|
33
|
-
|
|
33
|
+
// `config.customModels` → the `--settings` JSON that puts each model in the
|
|
34
|
+
// /model picker under its REAL id ({model, label?, description?} rows; typed
|
|
35
|
+
// `/model <id>` also accepts picker rows). contextTokens is ours, not Claude
|
|
36
|
+
// Code's — it feeds CLAUDE_CODE_MAX_CONTEXT_TOKENS below. Null when empty so
|
|
37
|
+
// callers can skip the flag entirely.
|
|
38
|
+
export function buildCustomModelSettings(customModels) {
|
|
39
|
+
if (!customModels?.length) return null;
|
|
40
|
+
const options = customModels.map(({ model, label, description }) => ({
|
|
41
|
+
model,
|
|
42
|
+
...(label ? { label } : {}),
|
|
43
|
+
...(description ? { description } : {}),
|
|
44
|
+
}));
|
|
45
|
+
return JSON.stringify({ modelPicker: { options } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `config.customModels` → the `--agents` JSON that makes each model
|
|
49
|
+
// dispatchable as a subagent. The Agent tool's per-invocation `model`
|
|
50
|
+
// parameter is an alias enum (sonnet|opus|haiku|fable) and rejects custom ids;
|
|
51
|
+
// an agent DEFINITION's `model:` field accepts any id, so each custom model
|
|
52
|
+
// gets a general-purpose agent named after it ("dispatch a gpt-5.6-terra
|
|
53
|
+
// subagent" then works out of the box). Null when empty.
|
|
54
|
+
export function buildCustomModelAgents(customModels) {
|
|
55
|
+
if (!customModels?.length) return null;
|
|
56
|
+
const agents = {};
|
|
57
|
+
for (const { model, label } of customModels) {
|
|
58
|
+
agents[model] = {
|
|
59
|
+
description: `General-purpose subagent running on ${label || model} (via TeamClaude). `
|
|
60
|
+
+ `Use when asked to run a task on ${model}.`,
|
|
61
|
+
prompt: `You are a general-purpose subagent running on the ${model} model. `
|
|
62
|
+
+ 'Complete the task you are given and report the results concisely.',
|
|
63
|
+
model,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify(agents);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The env-only registration for launchers we can't pass flags to (`teamclaude
|
|
70
|
+
// env`). ANTHROPIC_CUSTOM_MODEL_OPTION registers ONE model (env can't express a
|
|
71
|
+
// list — the picker rows need `--settings`, i.e. `teamclaude run`), so the
|
|
72
|
+
// first entry is the one that gets a picker row and typed-/model acceptance.
|
|
73
|
+
// CLAUDE_CODE_MAX_CONTEXT_TOKENS is global for all unknown model ids: use the
|
|
74
|
+
// largest declared window so no custom model is compacted early; deliberately
|
|
75
|
+
// NOT modelOverrides, which would pin the window to the mapped Claude model's.
|
|
76
|
+
export function buildCustomModelVars(customModels) {
|
|
77
|
+
if (!customModels?.length) return {};
|
|
78
|
+
const vars = { ANTHROPIC_CUSTOM_MODEL_OPTION: customModels[0].model };
|
|
79
|
+
if (customModels[0].label) vars.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = customModels[0].label;
|
|
80
|
+
if (customModels[0].description) vars.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = customModels[0].description;
|
|
81
|
+
const windows = customModels.map(m => m.contextTokens).filter(n => Number.isFinite(n));
|
|
82
|
+
if (windows.length) vars.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.max(...windows));
|
|
83
|
+
return vars;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Single-quote a value for an unquoted-context shell `export` line (labels and
|
|
87
|
+
// descriptions contain spaces). POSIX: close, escaped quote, reopen.
|
|
88
|
+
function shellQuote(value) {
|
|
89
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '', customModels = null }) {
|
|
34
93
|
const lines = [];
|
|
35
94
|
const pin = (account || '').trim();
|
|
36
95
|
|
|
@@ -61,5 +120,10 @@ export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdS
|
|
|
61
120
|
const holdMs = (holdSeconds || 0) * 1000;
|
|
62
121
|
if (holdMs > 0) lines.push(`export API_TIMEOUT_MS=${holdMs + 60_000}`);
|
|
63
122
|
|
|
123
|
+
// Custom (third-party) model registration — see buildCustomModelVars.
|
|
124
|
+
for (const [key, value] of Object.entries(buildCustomModelVars(customModels))) {
|
|
125
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
64
128
|
return lines;
|
|
65
129
|
}
|
package/src/config.js
CHANGED
|
@@ -59,7 +59,7 @@ export function createDefaultConfig() {
|
|
|
59
59
|
holdSeconds: 0,
|
|
60
60
|
distributeSessions: false,
|
|
61
61
|
projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
|
|
62
|
-
sessionTitles: { enabled:
|
|
62
|
+
sessionTitles: { enabled: false, width: 18 },
|
|
63
63
|
eventLogging: 'hide',
|
|
64
64
|
blockedModels: [],
|
|
65
65
|
accounts: [],
|
package/src/identity.js
CHANGED
|
@@ -94,3 +94,27 @@ export function matchAccounts(accounts, query, orgFilter) {
|
|
|
94
94
|
}
|
|
95
95
|
return matches;
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Automatic naming is safe only when the profile identifies the account.
|
|
100
|
+
* An explicit name is the caller's opt-in to importing without detection.
|
|
101
|
+
*/
|
|
102
|
+
export function canUpsertOAuthAccount(profile, userNamed) {
|
|
103
|
+
return Boolean(
|
|
104
|
+
userNamed
|
|
105
|
+
|| (profile && !profile.error && (profile.accountUuid || profile.email))
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Copy only known profile identity fields. Omitting unavailable fields keeps a
|
|
111
|
+
* named re-import from erasing identity already stored on the account.
|
|
112
|
+
*/
|
|
113
|
+
export function oauthIdentityFields(profile) {
|
|
114
|
+
if (!profile || profile.error) return {};
|
|
115
|
+
return Object.fromEntries(
|
|
116
|
+
['accountUuid', 'orgUuid', 'orgName']
|
|
117
|
+
.filter(key => profile[key])
|
|
118
|
+
.map(key => [key, profile[key]])
|
|
119
|
+
);
|
|
120
|
+
}
|