@karpeleslab/teamclaude 1.0.5 → 1.0.7
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/LICENSE +21 -0
- package/README.md +93 -14
- package/package.json +7 -2
- package/src/account-manager.js +219 -12
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/identity.js +65 -0
- package/src/index.js +458 -93
- package/src/oauth.js +80 -9
- package/src/server.js +97 -68
- package/src/tui.js +105 -12
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KarpelesLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
# TeamClaude
|
|
2
2
|
|
|
3
|
+
[](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@karpeleslab/teamclaude)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
3
8
|
Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
|
|
4
9
|
|
|
5
|
-
Sits transparently between Claude Code and the Anthropic API, managing multiple Claude Max accounts and automatically switching when one approaches its session or weekly quota limit.
|
|
10
|
+
Sits transparently between Claude Code and the Anthropic API, managing multiple Claude Max (or API key) accounts and automatically switching when one approaches its session or weekly quota limit.
|
|
6
11
|
|
|
7
12
|

|
|
8
13
|
|
|
9
14
|
## Features
|
|
10
15
|
|
|
11
16
|
- **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
|
|
12
|
-
- **Auto-retry on 429** —
|
|
13
|
-
- **Interactive TUI** — real-time dashboard with color-coded quota bars
|
|
14
|
-
- **OAuth token
|
|
17
|
+
- **Auto-retry on 429** — waits the `retry-after` duration and retries the same account; switches to the next on persistent errors
|
|
18
|
+
- **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls
|
|
19
|
+
- **OAuth token management** — automatically refreshes tokens nearing expiry and persists them to config; client token refreshes pass through untouched
|
|
15
20
|
- **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
|
|
21
|
+
- **Org-aware accounts** — one email can hold multiple accounts across different organizations (e.g. corp + personal); dedup is keyed on account + org, and names disambiguate as `email (Org)`
|
|
22
|
+
- **Rotation priority** — pin a preferred account order with `teamclaude priority`
|
|
23
|
+
- **Enable/disable accounts** — temporarily pause an account without removing it (`teamclaude disable`/`enable`, or `d` in the TUI); re-enabling also clears a stuck error state
|
|
24
|
+
- **Quota persistence** — observed quota survives restarts (saved to a sibling state file), so rotation state isn't lost on restart; stale windows are discarded automatically
|
|
16
25
|
- **Request logging** — optional full request/response logging for debugging
|
|
17
26
|
- **Zero dependencies** — uses only Node.js built-in modules
|
|
18
27
|
|
|
@@ -67,7 +76,11 @@ claude /login # Log into an account in Claude Code
|
|
|
67
76
|
teamclaude import # Import its credentials
|
|
68
77
|
```
|
|
69
78
|
|
|
70
|
-
Re-importing the same account updates its credentials.
|
|
79
|
+
Re-importing the same account updates its credentials. You can also import from a custom path:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
teamclaude import --from /path/to/credentials.json
|
|
83
|
+
```
|
|
71
84
|
|
|
72
85
|
### API Key
|
|
73
86
|
|
|
@@ -86,18 +99,33 @@ teamclaude server
|
|
|
86
99
|
```
|
|
87
100
|
|
|
88
101
|
When running from a TTY, shows an interactive TUI with:
|
|
89
|
-
- Account table with session/weekly quota progress bars
|
|
102
|
+
- Account table with session/weekly quota progress bars and reset countdowns
|
|
90
103
|
- Real-time activity log with request tracking
|
|
91
|
-
- Keyboard shortcuts
|
|
104
|
+
- Keyboard shortcuts (see below)
|
|
92
105
|
|
|
93
106
|
Falls back to plain log output when not a TTY (e.g. running as a service).
|
|
94
107
|
|
|
108
|
+
#### TUI Keyboard Shortcuts
|
|
109
|
+
|
|
110
|
+
| Key | Action |
|
|
111
|
+
|-----|--------|
|
|
112
|
+
| `s` | Switch active account |
|
|
113
|
+
| `a` | Add account (import or API key) |
|
|
114
|
+
| `r` | Remove an account |
|
|
115
|
+
| `d` | Enable/disable an account |
|
|
116
|
+
| `R` | Reload accounts from config |
|
|
117
|
+
| `q` | Quit |
|
|
118
|
+
|
|
119
|
+
In selection mode, use `j`/`k` or arrow keys to navigate, `Enter` to confirm, `Esc` to cancel.
|
|
120
|
+
|
|
95
121
|
### Run Claude Code through the proxy
|
|
96
122
|
|
|
97
123
|
```bash
|
|
98
124
|
teamclaude run
|
|
99
125
|
```
|
|
100
126
|
|
|
127
|
+
`run` probes the proxy first: if it's up, Claude Code is routed through it; if it's **not** running, `claude` is launched directly so nothing breaks.
|
|
128
|
+
|
|
101
129
|
Or manually set the environment:
|
|
102
130
|
|
|
103
131
|
```bash
|
|
@@ -105,16 +133,38 @@ eval $(teamclaude env)
|
|
|
105
133
|
claude
|
|
106
134
|
```
|
|
107
135
|
|
|
136
|
+
### Routing plain `claude` automatically (alias)
|
|
137
|
+
|
|
138
|
+
So you don't have to type `teamclaude run` every time, add a shell alias that makes plain `claude` go through the proxy (and fall back to direct when it's down):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
teamclaude alias # print the alias for your shell
|
|
142
|
+
teamclaude alias --install # or write it to your shell rc (--uninstall to remove)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
This is an interactive-shell alias — it affects `claude` typed at a prompt, not `claude` spawned by editors or scripts. It's a thin passthrough to `teamclaude run`, which holds the proxy-up/down logic.
|
|
146
|
+
|
|
108
147
|
### Other commands
|
|
109
148
|
|
|
110
149
|
```bash
|
|
111
|
-
teamclaude accounts # List accounts with
|
|
150
|
+
teamclaude accounts # List accounts with subscription tier and token status
|
|
151
|
+
teamclaude accounts -v # Also show token expiry times
|
|
112
152
|
teamclaude status # Show live proxy status (requires running server)
|
|
113
|
-
teamclaude remove <name> # Remove an account
|
|
153
|
+
teamclaude remove <name> # Remove an account (by name or email)
|
|
154
|
+
teamclaude disable <name> # Temporarily exclude an account from rotation
|
|
155
|
+
teamclaude enable <name> # Re-enable it (also clears a stuck error state)
|
|
156
|
+
teamclaude priority <name> 1 # Set rotation priority (lower = preferred)
|
|
157
|
+
teamclaude alias # Print/install a `claude` alias that routes via the proxy
|
|
114
158
|
teamclaude api <path> # Call an API endpoint with account credentials
|
|
115
159
|
teamclaude help # Show all commands
|
|
116
160
|
```
|
|
117
161
|
|
|
162
|
+
When the same email belongs to multiple organizations, accounts are named
|
|
163
|
+
`email (Org)` to keep them distinct. Pass `--org <name|uuid>` to disambiguate a
|
|
164
|
+
bare email, e.g. `teamclaude remove user@example.com --org Acme`. Use
|
|
165
|
+
`teamclaude priority <name> --first` / `--last` to move an account to the front
|
|
166
|
+
or back of the rotation order.
|
|
167
|
+
|
|
118
168
|
### Request logging
|
|
119
169
|
|
|
120
170
|
Log full request/response details to a directory (one file per request):
|
|
@@ -127,6 +177,8 @@ teamclaude server --log-to /tmp/requests
|
|
|
127
177
|
|
|
128
178
|
Config is stored at `~/.config/teamclaude.json` (or `$XDG_CONFIG_HOME/teamclaude.json`). A random proxy API key is generated on first use.
|
|
129
179
|
|
|
180
|
+
Volatile runtime state (observed quota) is written separately to `teamclaude.state.json` alongside the config, so the config file stays clean and hand-editable. The state file is safe to delete — quota is simply re-learned from traffic.
|
|
181
|
+
|
|
130
182
|
Override the config path with `TEAMCLAUDE_CONFIG`:
|
|
131
183
|
|
|
132
184
|
```bash
|
|
@@ -145,9 +197,12 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
|
|
|
145
197
|
"switchThreshold": 0.98,
|
|
146
198
|
"accounts": [
|
|
147
199
|
{
|
|
148
|
-
"name": "user@example.com",
|
|
200
|
+
"name": "user@example.com (Acme)",
|
|
149
201
|
"type": "oauth",
|
|
150
202
|
"accountUuid": "...",
|
|
203
|
+
"orgUuid": "...",
|
|
204
|
+
"orgName": "Acme",
|
|
205
|
+
"priority": 0,
|
|
151
206
|
"accessToken": "sk-ant-oat01-...",
|
|
152
207
|
"refreshToken": "sk-ant-ort01-...",
|
|
153
208
|
"expiresAt": 1774384968427
|
|
@@ -156,14 +211,38 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
|
|
|
156
211
|
}
|
|
157
212
|
```
|
|
158
213
|
|
|
214
|
+
| Field | Description |
|
|
215
|
+
|-------|-------------|
|
|
216
|
+
| `proxy.port` | Local port the proxy listens on |
|
|
217
|
+
| `proxy.apiKey` | API key clients use to authenticate with the proxy |
|
|
218
|
+
| `upstream` | Upstream API base URL |
|
|
219
|
+
| `switchThreshold` | Quota utilization (0–1) at which to switch accounts |
|
|
220
|
+
| `accounts[].accountUuid` | Anthropic account (person) id; set automatically from the OAuth profile |
|
|
221
|
+
| `accounts[].orgUuid` / `orgName` | Organization the account is scoped to — lets one email hold multiple org accounts |
|
|
222
|
+
| `accounts[].priority` | Rotation preference, lower = preferred (default 0) |
|
|
223
|
+
| `accounts[].disabled` | If `true`, the account is excluded from rotation until re-enabled |
|
|
224
|
+
|
|
159
225
|
## How It Works
|
|
160
226
|
|
|
161
227
|
1. Claude Code connects to the local proxy instead of `api.anthropic.com`
|
|
162
228
|
2. The proxy selects the active account and forwards requests with that account's credentials
|
|
163
|
-
3.
|
|
164
|
-
4.
|
|
165
|
-
5.
|
|
229
|
+
3. OAuth tokens expiring within 5 minutes are automatically refreshed and persisted to config
|
|
230
|
+
4. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session (5h) and weekly (7d) quota utilization
|
|
231
|
+
5. When usage reaches the threshold, the proxy switches to the next available account via round-robin
|
|
232
|
+
6. On 429 responses, the proxy waits the `retry-after` duration and retries; on persistent errors, it switches accounts
|
|
233
|
+
7. Transient network errors (connection reset, timeout) drop the connection so the client can retry
|
|
234
|
+
8. If all accounts are exhausted, returns 429 with the soonest reset time
|
|
235
|
+
9. Client token refresh requests (`/v1/oauth/token`) are relayed to upstream untouched — the proxy and client manage their own token lifecycles independently
|
|
236
|
+
|
|
237
|
+
## Security
|
|
238
|
+
|
|
239
|
+
The only canonical sources for TeamClaude are this repository
|
|
240
|
+
(https://github.com/KarpelesLab/teamclaude) and the
|
|
241
|
+
[`@karpeleslab/teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude)
|
|
242
|
+
npm package. TeamClaude is **never** distributed as a downloadable binary
|
|
243
|
+
archive — be wary of soft-forks that bundle a `.zip` and tell you to extract and
|
|
244
|
+
run it. See [SECURITY.md](SECURITY.md) for details and how to report issues.
|
|
166
245
|
|
|
167
246
|
## License
|
|
168
247
|
|
|
169
|
-
MIT
|
|
248
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karpeleslab/teamclaude",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Multi-account Claude proxy with automatic quota-based rotation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"src/"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"start": "node src/index.js"
|
|
14
|
+
"start": "node src/index.js",
|
|
15
|
+
"test": "node --test",
|
|
16
|
+
"lint": "eslint ."
|
|
15
17
|
},
|
|
16
18
|
"keywords": [
|
|
17
19
|
"claude",
|
|
@@ -23,5 +25,8 @@
|
|
|
23
25
|
"license": "MIT",
|
|
24
26
|
"engines": {
|
|
25
27
|
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"eslint": "^9.0.0"
|
|
26
31
|
}
|
|
27
32
|
}
|
package/src/account-manager.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
2
|
+
import { sameIdentity } from './identity.js';
|
|
3
|
+
|
|
4
|
+
// Quota fields that survive a restart: utilization levels and their reset
|
|
5
|
+
// windows, learned passively from upstream responses. Transient/derived state
|
|
6
|
+
// (probing, requalify, rateLimitedUntil) is intentionally excluded.
|
|
7
|
+
const PERSISTED_QUOTA_FIELDS = [
|
|
8
|
+
'unified5h', 'unified7d', 'unified5hReset', 'unified7dReset', 'unifiedStatus',
|
|
9
|
+
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
10
|
+
];
|
|
2
11
|
|
|
3
12
|
function emptyQuota() {
|
|
4
13
|
return {
|
|
@@ -24,10 +33,17 @@ export class AccountManager {
|
|
|
24
33
|
name: acct.name,
|
|
25
34
|
type: acct.type,
|
|
26
35
|
accountUuid: acct.accountUuid || null,
|
|
36
|
+
orgUuid: acct.orgUuid || null,
|
|
37
|
+
orgName: acct.orgName || null,
|
|
38
|
+
priority: acct.priority || 0,
|
|
39
|
+
disabled: acct.disabled || false,
|
|
27
40
|
credential: acct.accessToken || acct.apiKey,
|
|
28
41
|
refreshToken: acct.refreshToken || null,
|
|
29
42
|
expiresAt: acct.expiresAt || null,
|
|
30
43
|
status: 'active',
|
|
44
|
+
// No quota is known at startup, so start probing: the first response for
|
|
45
|
+
// an account reveals its weekly limit and triggers re-evaluation.
|
|
46
|
+
probing: true,
|
|
31
47
|
quota: emptyQuota(),
|
|
32
48
|
usage: {
|
|
33
49
|
totalInputTokens: 0,
|
|
@@ -46,9 +62,26 @@ export class AccountManager {
|
|
|
46
62
|
* Returns null if all accounts are exhausted.
|
|
47
63
|
*/
|
|
48
64
|
getActiveAccount() {
|
|
65
|
+
// Clear expired quotas across all accounts and switch proactively if a
|
|
66
|
+
// session reset made a sooner-expiring account the better choice. This runs
|
|
67
|
+
// on every request so the behaviour holds without the TUI render loop.
|
|
68
|
+
this.refreshExpiredQuotas();
|
|
49
69
|
const current = this.accounts[this.currentIndex];
|
|
70
|
+
// We just learned a probed account's weekly quota — re-evaluate which
|
|
71
|
+
// account is best now that its limit is known.
|
|
72
|
+
if (current && current.requalify) {
|
|
73
|
+
current.requalify = false;
|
|
74
|
+
const next = this._selectNext();
|
|
75
|
+
if (next) return next;
|
|
76
|
+
}
|
|
50
77
|
if (this._isAvailable(current)) {
|
|
51
|
-
|
|
78
|
+
// A strictly higher-priority (lower value) available account preempts a
|
|
79
|
+
// healthy current one. Within the same priority tier we stay put, so the
|
|
80
|
+
// common case (all accounts at the default priority 0) is unchanged and
|
|
81
|
+
// never thrashes — preemption only triggers when priorities differ.
|
|
82
|
+
const betterExists = this.accounts.some(a =>
|
|
83
|
+
this._isAvailable(a) && (a.priority || 0) < (current.priority || 0));
|
|
84
|
+
return betterExists ? this._selectNext() : current;
|
|
52
85
|
}
|
|
53
86
|
return this._selectNext();
|
|
54
87
|
}
|
|
@@ -56,6 +89,9 @@ export class AccountManager {
|
|
|
56
89
|
_isAvailable(account) {
|
|
57
90
|
if (!account) return false;
|
|
58
91
|
|
|
92
|
+
// Manually disabled accounts are skipped entirely until re-enabled.
|
|
93
|
+
if (account.disabled) return false;
|
|
94
|
+
|
|
59
95
|
// Check rate limit expiry
|
|
60
96
|
if (account.status === 'throttled' && account.rateLimitedUntil) {
|
|
61
97
|
if (Date.now() < account.rateLimitedUntil) return false;
|
|
@@ -70,21 +106,33 @@ export class AccountManager {
|
|
|
70
106
|
return true;
|
|
71
107
|
}
|
|
72
108
|
|
|
73
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Clear any quota counters whose reset time has passed. Cheap and safe to
|
|
111
|
+
* call frequently (e.g. from the TUI render loop) — once a counter is cleared
|
|
112
|
+
* it stays null until the next upstream response repopulates it, so the
|
|
113
|
+
* "reset" log fires at most once per window.
|
|
114
|
+
* @returns {{changed: boolean, session: boolean}} what was cleared.
|
|
115
|
+
*/
|
|
116
|
+
_clearExpiredQuotas(account) {
|
|
74
117
|
const q = account.quota;
|
|
75
118
|
const now = Date.now();
|
|
119
|
+
let changed = false;
|
|
120
|
+
let session = false;
|
|
76
121
|
|
|
77
122
|
// Clear expired unified quotas
|
|
78
123
|
if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
|
|
79
124
|
console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
|
|
80
125
|
q.unified5h = null;
|
|
81
126
|
q.unified5hReset = null;
|
|
127
|
+
changed = true;
|
|
128
|
+
session = true;
|
|
82
129
|
}
|
|
83
130
|
if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
|
|
84
131
|
console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
|
|
85
132
|
q.unified7d = null;
|
|
86
133
|
q.unified7dReset = null;
|
|
87
134
|
q.unifiedStatus = null;
|
|
135
|
+
changed = true;
|
|
88
136
|
}
|
|
89
137
|
|
|
90
138
|
// Clear expired standard quotas
|
|
@@ -94,7 +142,69 @@ export class AccountManager {
|
|
|
94
142
|
q.requestsRemaining = null;
|
|
95
143
|
q.requestsLimit = null;
|
|
96
144
|
q.resetsAt = null;
|
|
145
|
+
changed = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { changed, session };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Clear expired quotas across all accounts. Called from the display loop and
|
|
153
|
+
* the request path so a window expiry (e.g. the 5-hour session quota) resets
|
|
154
|
+
* the view instantly rather than waiting for the next request.
|
|
155
|
+
*
|
|
156
|
+
* When an account's session quota resets, it may have become the better
|
|
157
|
+
* choice — switch to it if its weekly limit expires sooner than the current
|
|
158
|
+
* account's (and it still has weekly quota), so we spend the quota closest to
|
|
159
|
+
* refreshing first.
|
|
160
|
+
*/
|
|
161
|
+
refreshExpiredQuotas() {
|
|
162
|
+
let changed = false;
|
|
163
|
+
const sessionReset = [];
|
|
164
|
+
for (const account of this.accounts) {
|
|
165
|
+
const r = this._clearExpiredQuotas(account);
|
|
166
|
+
if (r.changed) changed = true;
|
|
167
|
+
if (r.session) sessionReset.push(account);
|
|
97
168
|
}
|
|
169
|
+
if (sessionReset.length) this._switchOnSessionReset(sessionReset);
|
|
170
|
+
return changed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Given accounts whose session quota just reset, switch to the one whose
|
|
175
|
+
* weekly limit expires soonest — but only if that is sooner than the current
|
|
176
|
+
* account's weekly limit and the account still has weekly quota to spend.
|
|
177
|
+
*/
|
|
178
|
+
_switchOnSessionReset(candidates) {
|
|
179
|
+
const current = this.accounts[this.currentIndex];
|
|
180
|
+
// Need a known weekly reset on the current account to compare against;
|
|
181
|
+
// if it is unknown we are still probing it, so leave it alone.
|
|
182
|
+
if (!current || current.quota.unified7dReset == null) return;
|
|
183
|
+
|
|
184
|
+
let best = null;
|
|
185
|
+
let bestWeekly = current.quota.unified7dReset;
|
|
186
|
+
for (const acc of candidates) {
|
|
187
|
+
if (acc.index === this.currentIndex) continue;
|
|
188
|
+
if (!this._isAvailable(acc)) continue; // enough session & weekly quota left
|
|
189
|
+
// Don't demote to a lower-priority (higher value) account on a reset.
|
|
190
|
+
if ((acc.priority || 0) > (current.priority || 0)) continue;
|
|
191
|
+
const weekly = acc.quota.unified7dReset;
|
|
192
|
+
if (weekly == null) continue; // need a known weekly to compare
|
|
193
|
+
if (weekly < bestWeekly) {
|
|
194
|
+
bestWeekly = weekly;
|
|
195
|
+
best = acc;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (best) {
|
|
200
|
+
this.currentIndex = best.index;
|
|
201
|
+
console.log(`[TeamClaude] Account "${best.name}" session quota reset and weekly expires sooner — switching to it`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_isNearQuota(account) {
|
|
206
|
+
const q = account.quota;
|
|
207
|
+
this._clearExpiredQuotas(account);
|
|
98
208
|
|
|
99
209
|
// Unified quotas (Claude Max) — utilization is already 0-1
|
|
100
210
|
if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
|
|
@@ -115,17 +225,47 @@ export class AccountManager {
|
|
|
115
225
|
}
|
|
116
226
|
|
|
117
227
|
_selectNext() {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
228
|
+
// Selection order among available accounts:
|
|
229
|
+
// 1. lowest `priority` value (operator-controlled; default 0, lower = preferred)
|
|
230
|
+
// 2. then the account with no known weekly limit — using it lets us
|
|
231
|
+
// discover its quota
|
|
232
|
+
// 3. then the account whose weekly limit expires soonest: that quota is
|
|
233
|
+
// closest to refreshing, so spending it first preserves accounts whose
|
|
234
|
+
// weekly window resets further out.
|
|
235
|
+
// With all priorities at the default 0, this reduces to the original
|
|
236
|
+
// weekly-reset heuristic.
|
|
237
|
+
let best = null;
|
|
238
|
+
let bestPriority = Infinity;
|
|
239
|
+
let bestReset = Infinity;
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < this.accounts.length; i++) {
|
|
242
|
+
const account = this.accounts[i];
|
|
243
|
+
// _isAvailable filters out accounts at/above the switch threshold, so the
|
|
244
|
+
// soonest-expiring pick only ever lands on an account whose 5-hour quota
|
|
245
|
+
// is still below 98%.
|
|
246
|
+
if (!this._isAvailable(account)) continue;
|
|
247
|
+
|
|
248
|
+
const priority = account.priority || 0;
|
|
249
|
+
// Unknown weekly reset sorts first so we fill it in.
|
|
250
|
+
const weeklyReset = account.quota.unified7dReset || -Infinity;
|
|
251
|
+
if (priority < bestPriority ||
|
|
252
|
+
(priority === bestPriority && weeklyReset < bestReset)) {
|
|
253
|
+
bestPriority = priority;
|
|
254
|
+
bestReset = weeklyReset;
|
|
255
|
+
best = account;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
123
258
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
259
|
+
if (best) {
|
|
260
|
+
const switched = best.index !== this.currentIndex;
|
|
261
|
+
this.currentIndex = best.index;
|
|
262
|
+
// If we switched to an account whose weekly quota is still unknown, flag
|
|
263
|
+
// it so we re-evaluate once that quota is learned (see updateQuota).
|
|
264
|
+
best.probing = best.quota.unified7dReset == null;
|
|
265
|
+
if (switched) {
|
|
266
|
+
console.log(`[TeamClaude] Switched to account "${best.name}"`);
|
|
128
267
|
}
|
|
268
|
+
return best;
|
|
129
269
|
}
|
|
130
270
|
|
|
131
271
|
// All accounts unavailable — find the one that resets soonest
|
|
@@ -173,6 +313,14 @@ export class AccountManager {
|
|
|
173
313
|
if (r5h) account.quota.unified5hReset = parseInt(r5h, 10) * 1000;
|
|
174
314
|
if (r7d) account.quota.unified7dReset = parseInt(r7d, 10) * 1000;
|
|
175
315
|
|
|
316
|
+
// We switched to this account to discover its weekly quota; now that we
|
|
317
|
+
// know it, flag for re-evaluation so selection can pick the best account.
|
|
318
|
+
if (account.probing && account.quota.unified7dReset != null) {
|
|
319
|
+
account.probing = false;
|
|
320
|
+
account.requalify = true;
|
|
321
|
+
console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
|
|
322
|
+
}
|
|
323
|
+
|
|
176
324
|
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
177
325
|
if (uStatus) account.quota.unifiedStatus = uStatus;
|
|
178
326
|
|
|
@@ -216,6 +364,22 @@ export class AccountManager {
|
|
|
216
364
|
if (outputTokens) account.usage.totalOutputTokens += outputTokens;
|
|
217
365
|
}
|
|
218
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Enable or disable an account. A disabled account is skipped by rotation
|
|
369
|
+
* until re-enabled. Re-enabling also clears a stuck 'error' state (and any
|
|
370
|
+
* lingering rate-limit hold) so the account is retried immediately.
|
|
371
|
+
*/
|
|
372
|
+
setDisabled(accountIndex, disabled) {
|
|
373
|
+
const account = this.accounts[accountIndex];
|
|
374
|
+
if (!account) return;
|
|
375
|
+
account.disabled = disabled;
|
|
376
|
+
if (!disabled && account.status === 'error') {
|
|
377
|
+
account.status = 'active';
|
|
378
|
+
account.rateLimitedUntil = null;
|
|
379
|
+
console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
219
383
|
/**
|
|
220
384
|
* Mark an account as rate-limited for a given duration.
|
|
221
385
|
*/
|
|
@@ -252,7 +416,11 @@ export class AccountManager {
|
|
|
252
416
|
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
253
417
|
} catch (err) {
|
|
254
418
|
console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
|
|
255
|
-
|
|
419
|
+
// Only mark as error if the access token is actually expired;
|
|
420
|
+
// a failed proactive refresh shouldn't kill a still-valid token
|
|
421
|
+
if (!account.expiresAt || Date.now() >= account.expiresAt) {
|
|
422
|
+
account.status = 'error';
|
|
423
|
+
}
|
|
256
424
|
} finally {
|
|
257
425
|
account._refreshPromise = null;
|
|
258
426
|
}
|
|
@@ -297,10 +465,16 @@ export class AccountManager {
|
|
|
297
465
|
name: acctData.name,
|
|
298
466
|
type: acctData.type,
|
|
299
467
|
accountUuid: acctData.accountUuid || null,
|
|
468
|
+
orgUuid: acctData.orgUuid || null,
|
|
469
|
+
orgName: acctData.orgName || null,
|
|
470
|
+
priority: acctData.priority || 0,
|
|
471
|
+
disabled: acctData.disabled || false,
|
|
300
472
|
credential: acctData.accessToken || acctData.apiKey,
|
|
301
473
|
refreshToken: acctData.refreshToken || null,
|
|
302
474
|
expiresAt: acctData.expiresAt || null,
|
|
303
475
|
status: 'active',
|
|
476
|
+
// Unknown quota until the first response — probe it like startup accounts.
|
|
477
|
+
probing: true,
|
|
304
478
|
quota: emptyQuota(),
|
|
305
479
|
usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
|
|
306
480
|
rateLimitedUntil: null,
|
|
@@ -322,6 +496,36 @@ export class AccountManager {
|
|
|
322
496
|
}
|
|
323
497
|
}
|
|
324
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Serialize persistable quota state for all accounts (no credentials), keyed
|
|
501
|
+
* by account identity so it can be matched back after a restart.
|
|
502
|
+
*/
|
|
503
|
+
exportQuotaState() {
|
|
504
|
+
return this.accounts.map(a => {
|
|
505
|
+
const quota = {};
|
|
506
|
+
for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
|
|
507
|
+
return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, quota };
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Restore quota learned in a previous run. Matches saved entries to accounts
|
|
513
|
+
* by identity. Stale windows are not special-cased here — _clearExpiredQuotas
|
|
514
|
+
* wipes any restored window whose reset time has already passed on first use.
|
|
515
|
+
*/
|
|
516
|
+
restoreQuotaState(saved) {
|
|
517
|
+
if (!Array.isArray(saved)) return;
|
|
518
|
+
for (const account of this.accounts) {
|
|
519
|
+
const match = saved.find(s => sameIdentity(s, account));
|
|
520
|
+
if (!match || !match.quota) continue;
|
|
521
|
+
for (const f of PERSISTED_QUOTA_FIELDS) {
|
|
522
|
+
if (match.quota[f] != null) account.quota[f] = match.quota[f];
|
|
523
|
+
}
|
|
524
|
+
// We already know this account's weekly window, so it isn't "probing".
|
|
525
|
+
if (account.quota.unified7dReset != null) account.probing = false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
325
529
|
/**
|
|
326
530
|
* Return a status summary of all accounts (safe to expose, no credentials).
|
|
327
531
|
*/
|
|
@@ -332,6 +536,9 @@ export class AccountManager {
|
|
|
332
536
|
accounts: this.accounts.map(a => ({
|
|
333
537
|
name: a.name,
|
|
334
538
|
type: a.type,
|
|
539
|
+
orgName: a.orgName || null,
|
|
540
|
+
priority: a.priority || 0,
|
|
541
|
+
disabled: a.disabled || false,
|
|
335
542
|
status: a.status,
|
|
336
543
|
quota: { ...a.quota },
|
|
337
544
|
usage: { ...a.usage },
|