@karpeleslab/teamclaude 1.0.0
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 +169 -0
- package/package.json +27 -0
- package/src/account-manager.js +323 -0
- package/src/config.js +48 -0
- package/src/index.js +577 -0
- package/src/oauth.js +220 -0
- package/src/server.js +351 -0
- package/src/tui.js +388 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# TeamClaude
|
|
2
|
+
|
|
3
|
+
Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
|
|
4
|
+
|
|
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.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **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** — if an account is rate-limited, transparently retries with the next one
|
|
13
|
+
- **Interactive TUI** — real-time dashboard with quota bars, activity log, and keyboard controls
|
|
14
|
+
- **OAuth token refresh** — proactively refreshes expiring tokens and persists them to config
|
|
15
|
+
- **Request logging** — optional full request/response logging for debugging
|
|
16
|
+
- **Zero dependencies** — uses only Node.js built-in modules
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
Requires Node.js 18+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Install
|
|
24
|
+
npm install -g @karpeleslab/teamclaude
|
|
25
|
+
|
|
26
|
+
# Import your current Claude Code credentials
|
|
27
|
+
teamclaude import
|
|
28
|
+
|
|
29
|
+
# Import a second account (log into it in Claude Code first, then import)
|
|
30
|
+
# claude /login
|
|
31
|
+
# teamclaude import
|
|
32
|
+
|
|
33
|
+
# Start the proxy
|
|
34
|
+
teamclaude server
|
|
35
|
+
|
|
36
|
+
# In another terminal, run Claude Code through the proxy
|
|
37
|
+
teamclaude run
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Adding Accounts
|
|
41
|
+
|
|
42
|
+
### Import from Claude Code (recommended)
|
|
43
|
+
|
|
44
|
+
The easiest way to add accounts. Log into each account in Claude Code, then import:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Log into your first account in Claude Code
|
|
48
|
+
claude /login
|
|
49
|
+
|
|
50
|
+
# Import it
|
|
51
|
+
teamclaude import
|
|
52
|
+
|
|
53
|
+
# Switch to another account in Claude Code
|
|
54
|
+
claude /login
|
|
55
|
+
|
|
56
|
+
# Import that one too
|
|
57
|
+
teamclaude import
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Each import auto-detects the account email and subscription tier. Re-importing the same account updates its credentials.
|
|
61
|
+
|
|
62
|
+
### API Key
|
|
63
|
+
|
|
64
|
+
For Anthropic API key accounts (billed via Console):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
teamclaude login --api
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### OAuth Login (experimental)
|
|
71
|
+
|
|
72
|
+
Direct browser-based OAuth login without needing Claude Code:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
teamclaude login
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> **Note:** OAuth login is currently experimental. Tokens obtained this way may not work for proxying `/v1/messages` requests. Use `teamclaude import` as the reliable method.
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
### Start the proxy server
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
teamclaude server
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
When running from a TTY, shows an interactive TUI with:
|
|
89
|
+
- Account table with session/weekly quota progress bars
|
|
90
|
+
- Real-time activity log with request tracking
|
|
91
|
+
- Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **q**uit
|
|
92
|
+
|
|
93
|
+
Falls back to plain log output when not a TTY (e.g. running as a service).
|
|
94
|
+
|
|
95
|
+
### Run Claude Code through the proxy
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
teamclaude run
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Or manually set the environment:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
eval $(teamclaude env)
|
|
105
|
+
claude
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Other commands
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
teamclaude accounts # List accounts with live profile info
|
|
112
|
+
teamclaude status # Show live proxy status (requires running server)
|
|
113
|
+
teamclaude remove <name> # Remove an account
|
|
114
|
+
teamclaude api <path> # Call an API endpoint with account credentials
|
|
115
|
+
teamclaude help # Show all commands
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Request logging
|
|
119
|
+
|
|
120
|
+
Log full request/response details to a directory (one file per request):
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
teamclaude server --log-to /tmp/requests
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
Config is stored at `~/.config/teamclaude.json` (or `$XDG_CONFIG_HOME/teamclaude.json`). A random proxy API key is generated on first use.
|
|
129
|
+
|
|
130
|
+
Override the config path with `TEAMCLAUDE_CONFIG`:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Config format
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"proxy": {
|
|
141
|
+
"port": 3456,
|
|
142
|
+
"apiKey": "tc-auto-generated-key"
|
|
143
|
+
},
|
|
144
|
+
"upstream": "https://api.anthropic.com",
|
|
145
|
+
"switchThreshold": 0.98,
|
|
146
|
+
"accounts": [
|
|
147
|
+
{
|
|
148
|
+
"name": "user@example.com",
|
|
149
|
+
"type": "oauth",
|
|
150
|
+
"accountUuid": "...",
|
|
151
|
+
"accessToken": "sk-ant-oat01-...",
|
|
152
|
+
"refreshToken": "sk-ant-ort01-...",
|
|
153
|
+
"expiresAt": 1774384968427
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## How It Works
|
|
160
|
+
|
|
161
|
+
1. Claude Code connects to the local proxy instead of `api.anthropic.com`
|
|
162
|
+
2. The proxy selects the active account and forwards requests with that account's credentials
|
|
163
|
+
3. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session and weekly quota
|
|
164
|
+
4. When usage reaches the threshold, the proxy switches to the next available account
|
|
165
|
+
5. If all accounts are exhausted, returns 429 with the soonest reset time
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karpeleslab/teamclaude",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-account Claude proxy with automatic quota-based rotation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"teamclaude": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/index.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude",
|
|
18
|
+
"anthropic",
|
|
19
|
+
"proxy",
|
|
20
|
+
"load-balancer",
|
|
21
|
+
"multi-account"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
2
|
+
|
|
3
|
+
function emptyQuota() {
|
|
4
|
+
return {
|
|
5
|
+
// Standard API rate limits (API key accounts)
|
|
6
|
+
tokensLimit: null,
|
|
7
|
+
tokensRemaining: null,
|
|
8
|
+
requestsLimit: null,
|
|
9
|
+
requestsRemaining: null,
|
|
10
|
+
// Unified rate limits (Claude Max accounts)
|
|
11
|
+
unified5h: null, // utilization 0-1
|
|
12
|
+
unified7d: null, // utilization 0-1
|
|
13
|
+
unified5hReset: null, // ms timestamp
|
|
14
|
+
unified7dReset: null, // ms timestamp
|
|
15
|
+
unifiedStatus: null, // allowed | allowed_warning | rejected
|
|
16
|
+
resetsAt: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class AccountManager {
|
|
21
|
+
constructor(accounts, switchThreshold = 0.98) {
|
|
22
|
+
this.accounts = accounts.map((acct, index) => ({
|
|
23
|
+
index,
|
|
24
|
+
name: acct.name,
|
|
25
|
+
type: acct.type,
|
|
26
|
+
credential: acct.accessToken || acct.apiKey,
|
|
27
|
+
refreshToken: acct.refreshToken || null,
|
|
28
|
+
expiresAt: acct.expiresAt || null,
|
|
29
|
+
status: 'active',
|
|
30
|
+
quota: emptyQuota(),
|
|
31
|
+
usage: {
|
|
32
|
+
totalInputTokens: 0,
|
|
33
|
+
totalOutputTokens: 0,
|
|
34
|
+
totalRequests: 0,
|
|
35
|
+
lastUsed: null,
|
|
36
|
+
},
|
|
37
|
+
rateLimitedUntil: null,
|
|
38
|
+
}));
|
|
39
|
+
this.currentIndex = 0;
|
|
40
|
+
this.switchThreshold = switchThreshold;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the best available account, rotating if the current one is near quota.
|
|
45
|
+
* Returns null if all accounts are exhausted.
|
|
46
|
+
*/
|
|
47
|
+
getActiveAccount() {
|
|
48
|
+
const current = this.accounts[this.currentIndex];
|
|
49
|
+
if (this._isAvailable(current)) {
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
return this._selectNext();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_isAvailable(account) {
|
|
56
|
+
if (!account) return false;
|
|
57
|
+
|
|
58
|
+
// Check rate limit expiry
|
|
59
|
+
if (account.status === 'throttled' && account.rateLimitedUntil) {
|
|
60
|
+
if (Date.now() < account.rateLimitedUntil) return false;
|
|
61
|
+
account.status = 'active';
|
|
62
|
+
account.rateLimitedUntil = null;
|
|
63
|
+
console.log(`[TeamClaude] Account "${account.name}" rate limit expired, marking active`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (account.status === 'exhausted' || account.status === 'error') return false;
|
|
67
|
+
if (this._isNearQuota(account)) return false;
|
|
68
|
+
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_isNearQuota(account) {
|
|
73
|
+
const q = account.quota;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
|
|
76
|
+
// Clear expired unified quotas
|
|
77
|
+
if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
|
|
78
|
+
console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
|
|
79
|
+
q.unified5h = null;
|
|
80
|
+
q.unified5hReset = null;
|
|
81
|
+
}
|
|
82
|
+
if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
|
|
83
|
+
console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
|
|
84
|
+
q.unified7d = null;
|
|
85
|
+
q.unified7dReset = null;
|
|
86
|
+
q.unifiedStatus = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Clear expired standard quotas
|
|
90
|
+
if (q.resetsAt && now >= new Date(q.resetsAt).getTime()) {
|
|
91
|
+
q.tokensRemaining = null;
|
|
92
|
+
q.tokensLimit = null;
|
|
93
|
+
q.requestsRemaining = null;
|
|
94
|
+
q.requestsLimit = null;
|
|
95
|
+
q.resetsAt = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Unified quotas (Claude Max) — utilization is already 0-1
|
|
99
|
+
if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
|
|
100
|
+
if (q.unified7d != null && q.unified7d >= this.switchThreshold) return true;
|
|
101
|
+
|
|
102
|
+
// Standard quotas (API key accounts)
|
|
103
|
+
if (q.tokensLimit != null && q.tokensRemaining != null) {
|
|
104
|
+
const used = 1 - (q.tokensRemaining / q.tokensLimit);
|
|
105
|
+
if (used >= this.switchThreshold) return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (q.requestsLimit != null && q.requestsRemaining != null) {
|
|
109
|
+
const used = 1 - (q.requestsRemaining / q.requestsLimit);
|
|
110
|
+
if (used >= this.switchThreshold) return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_selectNext() {
|
|
117
|
+
const startIndex = this.currentIndex;
|
|
118
|
+
|
|
119
|
+
for (let i = 1; i <= this.accounts.length; i++) {
|
|
120
|
+
const idx = (startIndex + i) % this.accounts.length;
|
|
121
|
+
const account = this.accounts[idx];
|
|
122
|
+
|
|
123
|
+
if (this._isAvailable(account)) {
|
|
124
|
+
this.currentIndex = idx;
|
|
125
|
+
console.log(`[TeamClaude] Switched to account "${account.name}"`);
|
|
126
|
+
return account;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// All accounts unavailable — find the one that resets soonest
|
|
131
|
+
let soonestAccount = null;
|
|
132
|
+
let soonestTime = Infinity;
|
|
133
|
+
|
|
134
|
+
for (const account of this.accounts) {
|
|
135
|
+
const resetTime = account.rateLimitedUntil
|
|
136
|
+
|| account.quota.unified5hReset
|
|
137
|
+
|| account.quota.unified7dReset
|
|
138
|
+
|| (account.quota.resetsAt ? new Date(account.quota.resetsAt).getTime() : null);
|
|
139
|
+
|
|
140
|
+
if (resetTime && resetTime < soonestTime) {
|
|
141
|
+
soonestTime = resetTime;
|
|
142
|
+
soonestAccount = account;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (soonestAccount && soonestTime <= Date.now()) {
|
|
147
|
+
soonestAccount.status = 'active';
|
|
148
|
+
soonestAccount.rateLimitedUntil = null;
|
|
149
|
+
this.currentIndex = soonestAccount.index;
|
|
150
|
+
console.log(`[TeamClaude] Account "${soonestAccount.name}" reset, switching to it`);
|
|
151
|
+
return soonestAccount;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Update an account's quota tracking from upstream response headers.
|
|
159
|
+
*/
|
|
160
|
+
updateQuota(accountIndex, headers) {
|
|
161
|
+
const account = this.accounts[accountIndex];
|
|
162
|
+
if (!account) return;
|
|
163
|
+
|
|
164
|
+
// Unified rate limits (Claude Max)
|
|
165
|
+
const u5h = parseFloat(headers['anthropic-ratelimit-unified-5h-utilization']);
|
|
166
|
+
const u7d = parseFloat(headers['anthropic-ratelimit-unified-7d-utilization']);
|
|
167
|
+
if (!isNaN(u5h)) account.quota.unified5h = u5h;
|
|
168
|
+
if (!isNaN(u7d)) account.quota.unified7d = u7d;
|
|
169
|
+
|
|
170
|
+
const r5h = headers['anthropic-ratelimit-unified-5h-reset'];
|
|
171
|
+
const r7d = headers['anthropic-ratelimit-unified-7d-reset'];
|
|
172
|
+
if (r5h) account.quota.unified5hReset = parseInt(r5h, 10) * 1000;
|
|
173
|
+
if (r7d) account.quota.unified7dReset = parseInt(r7d, 10) * 1000;
|
|
174
|
+
|
|
175
|
+
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
176
|
+
if (uStatus) account.quota.unifiedStatus = uStatus;
|
|
177
|
+
|
|
178
|
+
// Standard rate limits (API key accounts)
|
|
179
|
+
const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
|
|
180
|
+
const tokensRemaining = parseInt(headers['anthropic-ratelimit-tokens-remaining'], 10);
|
|
181
|
+
const tokensReset = headers['anthropic-ratelimit-tokens-reset'];
|
|
182
|
+
const requestsLimit = parseInt(headers['anthropic-ratelimit-requests-limit'], 10);
|
|
183
|
+
const requestsRemaining = parseInt(headers['anthropic-ratelimit-requests-remaining'], 10);
|
|
184
|
+
const requestsReset = headers['anthropic-ratelimit-requests-reset'];
|
|
185
|
+
|
|
186
|
+
if (!isNaN(tokensLimit)) account.quota.tokensLimit = tokensLimit;
|
|
187
|
+
if (!isNaN(tokensRemaining)) account.quota.tokensRemaining = tokensRemaining;
|
|
188
|
+
if (!isNaN(requestsLimit)) account.quota.requestsLimit = requestsLimit;
|
|
189
|
+
if (!isNaN(requestsRemaining)) account.quota.requestsRemaining = requestsRemaining;
|
|
190
|
+
|
|
191
|
+
if (tokensReset) account.quota.resetsAt = tokensReset;
|
|
192
|
+
else if (requestsReset) account.quota.resetsAt = requestsReset;
|
|
193
|
+
|
|
194
|
+
account.usage.totalRequests++;
|
|
195
|
+
account.usage.lastUsed = new Date().toISOString();
|
|
196
|
+
|
|
197
|
+
// Log when approaching quota
|
|
198
|
+
if (this._isNearQuota(account)) {
|
|
199
|
+
const pct = account.quota.unified7d != null
|
|
200
|
+
? (account.quota.unified7d * 100).toFixed(1)
|
|
201
|
+
: account.quota.tokensLimit
|
|
202
|
+
? ((1 - account.quota.tokensRemaining / account.quota.tokensLimit) * 100).toFixed(1)
|
|
203
|
+
: '?';
|
|
204
|
+
console.log(`[TeamClaude] Account "${account.name}" at ${pct}% usage — will switch on next request`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Update cumulative token usage from response body data.
|
|
210
|
+
*/
|
|
211
|
+
updateUsage(accountIndex, inputTokens, outputTokens) {
|
|
212
|
+
const account = this.accounts[accountIndex];
|
|
213
|
+
if (!account) return;
|
|
214
|
+
if (inputTokens) account.usage.totalInputTokens += inputTokens;
|
|
215
|
+
if (outputTokens) account.usage.totalOutputTokens += outputTokens;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Mark an account as rate-limited for a given duration.
|
|
220
|
+
*/
|
|
221
|
+
markRateLimited(accountIndex, retryAfterSeconds) {
|
|
222
|
+
const account = this.accounts[accountIndex];
|
|
223
|
+
if (!account) return;
|
|
224
|
+
account.status = 'throttled';
|
|
225
|
+
account.rateLimitedUntil = Date.now() + (retryAfterSeconds * 1000);
|
|
226
|
+
console.log(`[TeamClaude] Account "${account.name}" rate limited for ${retryAfterSeconds}s`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Ensure an OAuth account's token is fresh, refreshing if needed.
|
|
231
|
+
* Pass force=true to refresh regardless of expiry (e.g. after a 401).
|
|
232
|
+
* Concurrent calls for the same account coalesce into a single refresh.
|
|
233
|
+
*/
|
|
234
|
+
async ensureTokenFresh(accountIndex, force = false) {
|
|
235
|
+
const account = this.accounts[accountIndex];
|
|
236
|
+
if (!account || account.type !== 'oauth' || !account.refreshToken) return;
|
|
237
|
+
|
|
238
|
+
if (!force && !isTokenExpiringSoon(account.expiresAt)) return;
|
|
239
|
+
|
|
240
|
+
// Coalesce concurrent refreshes
|
|
241
|
+
if (account._refreshPromise) return account._refreshPromise;
|
|
242
|
+
|
|
243
|
+
account._refreshPromise = (async () => {
|
|
244
|
+
console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
|
|
245
|
+
try {
|
|
246
|
+
const newTokens = await refreshAccessToken(account.refreshToken);
|
|
247
|
+
account.credential = newTokens.accessToken;
|
|
248
|
+
account.refreshToken = newTokens.refreshToken;
|
|
249
|
+
account.expiresAt = newTokens.expiresAt;
|
|
250
|
+
console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
|
|
251
|
+
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
|
|
254
|
+
account.status = 'error';
|
|
255
|
+
} finally {
|
|
256
|
+
account._refreshPromise = null;
|
|
257
|
+
}
|
|
258
|
+
})();
|
|
259
|
+
|
|
260
|
+
return account._refreshPromise;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Set a callback to persist refreshed tokens to config.
|
|
265
|
+
*/
|
|
266
|
+
onTokenRefresh(callback) {
|
|
267
|
+
this._onTokenRefresh = callback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Add a new account at runtime.
|
|
272
|
+
*/
|
|
273
|
+
addAccount(acctData) {
|
|
274
|
+
const index = this.accounts.length;
|
|
275
|
+
this.accounts.push({
|
|
276
|
+
index,
|
|
277
|
+
name: acctData.name,
|
|
278
|
+
type: acctData.type,
|
|
279
|
+
credential: acctData.accessToken || acctData.apiKey,
|
|
280
|
+
refreshToken: acctData.refreshToken || null,
|
|
281
|
+
expiresAt: acctData.expiresAt || null,
|
|
282
|
+
status: 'active',
|
|
283
|
+
quota: emptyQuota(),
|
|
284
|
+
usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
|
|
285
|
+
rateLimitedUntil: null,
|
|
286
|
+
});
|
|
287
|
+
return index;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Remove an account by index.
|
|
292
|
+
*/
|
|
293
|
+
removeAccount(index) {
|
|
294
|
+
if (index < 0 || index >= this.accounts.length) return;
|
|
295
|
+
this.accounts.splice(index, 1);
|
|
296
|
+
this.accounts.forEach((a, i) => a.index = i);
|
|
297
|
+
if (this.currentIndex >= this.accounts.length) {
|
|
298
|
+
this.currentIndex = Math.max(0, this.accounts.length - 1);
|
|
299
|
+
} else if (this.currentIndex > index) {
|
|
300
|
+
this.currentIndex--;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Return a status summary of all accounts (safe to expose, no credentials).
|
|
306
|
+
*/
|
|
307
|
+
getStatus() {
|
|
308
|
+
return {
|
|
309
|
+
currentAccount: this.accounts[this.currentIndex]?.name,
|
|
310
|
+
switchThreshold: this.switchThreshold,
|
|
311
|
+
accounts: this.accounts.map(a => ({
|
|
312
|
+
name: a.name,
|
|
313
|
+
type: a.type,
|
|
314
|
+
status: a.status,
|
|
315
|
+
quota: { ...a.quota },
|
|
316
|
+
usage: { ...a.usage },
|
|
317
|
+
rateLimitedUntil: a.rateLimitedUntil
|
|
318
|
+
? new Date(a.rateLimitedUntil).toISOString()
|
|
319
|
+
: null,
|
|
320
|
+
})),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function getConfigPath() {
|
|
7
|
+
if (process.env.TEAMCLAUDE_CONFIG) return process.env.TEAMCLAUDE_CONFIG;
|
|
8
|
+
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
9
|
+
return join(configDir, 'teamclaude.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createDefaultConfig() {
|
|
13
|
+
return {
|
|
14
|
+
proxy: {
|
|
15
|
+
port: 3456,
|
|
16
|
+
apiKey: 'tc-' + randomBytes(24).toString('base64url'),
|
|
17
|
+
},
|
|
18
|
+
upstream: 'https://api.anthropic.com',
|
|
19
|
+
switchThreshold: 0.98,
|
|
20
|
+
accounts: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadConfig() {
|
|
25
|
+
const path = getConfigPath();
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === 'ENOENT') return null;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function loadOrCreateConfig() {
|
|
35
|
+
let config = await loadConfig();
|
|
36
|
+
if (!config) {
|
|
37
|
+
config = createDefaultConfig();
|
|
38
|
+
await saveConfig(config);
|
|
39
|
+
console.log(`Created config at ${getConfigPath()}`);
|
|
40
|
+
}
|
|
41
|
+
return config;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function saveConfig(config) {
|
|
45
|
+
const path = getConfigPath();
|
|
46
|
+
await mkdir(dirname(path), { recursive: true });
|
|
47
|
+
await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
48
|
+
}
|