@rikcodes/teamclaude 1.1.13-rik.1

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/src/oauth.js ADDED
@@ -0,0 +1,459 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { randomBytes, createHash } from 'node:crypto';
4
+ import { exec, execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { createInterface } from 'node:readline';
7
+ import http from 'node:http';
8
+ import { proxyFetch } from './upstream-fetch.js';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const DEFAULT_CREDENTIALS_PATH = '~/.claude/.credentials.json';
13
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
14
+
15
+ /**
16
+ * Read Claude Code credentials from the macOS Keychain, where Claude Code
17
+ * stores them on darwin (there is no ~/.claude/.credentials.json on macOS).
18
+ */
19
+ async function readKeychainCredentials() {
20
+ const { stdout } = await execFileAsync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
21
+ return JSON.parse(stdout.trim());
22
+ }
23
+
24
+ /**
25
+ * Import OAuth credentials from a Claude Code credentials file.
26
+ * On macOS the default credentials location is the Keychain, not a file, so
27
+ * when the default path is missing the Keychain is tried before giving up.
28
+ */
29
+ export async function importCredentials(filePath, {
30
+ home = homedir(), platform = process.platform, readKeychain = readKeychainCredentials } = {}) {
31
+ const resolvedPath = filePath.replace(/^~/, home);
32
+ let raw;
33
+ try {
34
+ raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
35
+ } catch (err) {
36
+ const isDefaultPath = resolvedPath === DEFAULT_CREDENTIALS_PATH.replace(/^~/, home);
37
+ if (err.code !== 'ENOENT' || platform !== 'darwin' || !isDefaultPath) throw err;
38
+ try {
39
+ raw = await readKeychain();
40
+ } catch (kcErr) {
41
+ throw new Error(`${err.message}; macOS Keychain lookup for "${KEYCHAIN_SERVICE}" also failed: ${kcErr.message}`);
42
+ }
43
+ }
44
+
45
+ // Claude Code stores credentials nested under "claudeAiOauth"
46
+ const data = raw.claudeAiOauth || raw;
47
+ return {
48
+ accessToken: data.accessToken,
49
+ refreshToken: data.refreshToken,
50
+ expiresAt: data.expiresAt,
51
+ subscriptionType: data.subscriptionType,
52
+ rateLimitTier: data.rateLimitTier,
53
+ };
54
+ }
55
+
56
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
57
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
58
+ const OAUTH_USAGE_BETA = 'oauth-2025-04-20';
59
+ const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
60
+ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
61
+
62
+ /**
63
+ * Refresh an expired OAuth access token using the refresh token.
64
+ * Retries on 5xx and network errors with exponential backoff.
65
+ */
66
+ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
67
+ const maxRetries = 2;
68
+ const baseDelayMs = 500;
69
+ // Bound each attempt so a dead pooled socket (after a network drop/reconnect)
70
+ // can't hang the refresh forever. A hung refresh is especially harmful here:
71
+ // ensureTokenFresh coalesces callers into a single _refreshPromise, so one
72
+ // stuck refresh wedges every request for that account until a restart.
73
+ const timeoutMs = Number(process.env.TEAMCLAUDE_REFRESH_TIMEOUT_MS) || 30_000;
74
+
75
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
76
+ try {
77
+ if (attempt > 0) {
78
+ const delay = baseDelayMs * 2 ** (attempt - 1);
79
+ await new Promise(resolve => setTimeout(resolve, delay));
80
+ }
81
+
82
+ const res = await proxyFetch(endpoint, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'Accept': 'application/json, text/plain, */*',
87
+ 'User-Agent': 'axios/1.13.6',
88
+ },
89
+ body: JSON.stringify({
90
+ grant_type: 'refresh_token',
91
+ refresh_token: refreshToken,
92
+ client_id: DEFAULT_CLIENT_ID,
93
+ }),
94
+ signal: AbortSignal.timeout(timeoutMs),
95
+ });
96
+
97
+ if (!res.ok) {
98
+ if (res.status >= 500 && attempt < maxRetries) {
99
+ await res.body?.cancel();
100
+ continue;
101
+ }
102
+ const text = await res.text();
103
+ const err = new Error(`Token refresh failed (${res.status}): ${text}`);
104
+ // Surface the HTTP status so callers can distinguish a genuine auth
105
+ // rejection (the refresh token is dead — re-login needed) from a
106
+ // transient server error. 5xx is retried above; reaching here with a 5xx
107
+ // means retries were exhausted, which is still transient, not auth.
108
+ err.status = res.status;
109
+ throw err;
110
+ }
111
+
112
+ const data = await res.json();
113
+ return {
114
+ accessToken: data.access_token,
115
+ refreshToken: data.refresh_token || refreshToken,
116
+ expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
117
+ };
118
+ } catch (err) {
119
+ const isNetworkError = err instanceof Error &&
120
+ (err.name === 'TimeoutError' || err.name === 'AbortError' ||
121
+ err.message.includes('fetch failed') ||
122
+ (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
123
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
124
+
125
+ if (attempt < maxRetries && isNetworkError) {
126
+ continue;
127
+ }
128
+ throw err;
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Normalize an expires_at value to milliseconds.
135
+ * OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
136
+ */
137
+ export function normalizeExpiresAt(expiresAt) {
138
+ if (!expiresAt) return expiresAt;
139
+ // If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
140
+ // convert to milliseconds
141
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
142
+ }
143
+
144
+ /**
145
+ * Check if an OAuth token is expiring within the given threshold.
146
+ */
147
+ export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
148
+ if (!expiresAt) return false;
149
+ return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
150
+ }
151
+
152
+ /**
153
+ * Check if an OAuth token has ALREADY expired (no safety margin). Used to decide
154
+ * when a token must be refreshed synchronously before it can be injected — a
155
+ * still-valid-but-expiring-soon token is fine to use now and refresh in the
156
+ * background, but an expired one would 401.
157
+ */
158
+ export function isTokenExpired(expiresAt) {
159
+ if (!expiresAt) return false;
160
+ return Date.now() >= normalizeExpiresAt(expiresAt);
161
+ }
162
+
163
+ /**
164
+ * Fetch account profile for an OAuth token.
165
+ * Returns { email, name, orgName, orgType, ... } on success,
166
+ * or { error: 'reason' } on failure.
167
+ */
168
+ export async function fetchProfile(accessToken) {
169
+ try {
170
+ const res = await proxyFetch(PROFILE_URL, {
171
+ headers: { 'Authorization': `Bearer ${accessToken}` },
172
+ });
173
+ if (!res.ok) {
174
+ let detail = '';
175
+ try {
176
+ const body = await res.json();
177
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
178
+ } catch {
179
+ detail = await res.text().catch(() => '');
180
+ }
181
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
182
+ }
183
+ const data = await res.json();
184
+ return {
185
+ accountUuid: data.account?.uuid,
186
+ email: data.account?.email,
187
+ name: data.account?.display_name,
188
+ orgUuid: data.organization?.uuid,
189
+ orgName: data.organization?.name,
190
+ orgType: data.organization?.organization_type,
191
+ hasClaudeMax: data.account?.has_claude_max,
192
+ hasClaudePro: data.account?.has_claude_pro,
193
+ };
194
+ } catch (err) {
195
+ return { error: err.message || String(err) };
196
+ }
197
+ }
198
+
199
+ // Pull a per-model weekly limit out of the payload's `limits[]` array, which is
200
+ // where the endpoint now reports model-scoped quota (a `weekly_scoped` entry
201
+ // carrying `scope.model.display_name`). Returns a bucket-shaped object
202
+ // { utilization, resets_at } ready for normalizeUsageBucket, or null if absent.
203
+ // The legacy top-level `seven_day_<model>` keys read null on current plans.
204
+ export function findScopedWeeklyLimit(data, modelNamePattern) {
205
+ const limits = Array.isArray(data?.limits) ? data.limits : [];
206
+ const entry = limits.find((l) =>
207
+ l && l.group === 'weekly' && l.scope?.model?.display_name
208
+ && modelNamePattern.test(l.scope.model.display_name));
209
+ if (!entry) return null;
210
+ return { utilization: entry.percent, resets_at: entry.resets_at };
211
+ }
212
+
213
+ // Normalize one usage bucket from the /api/oauth/usage payload into
214
+ // { utilization: 0-1, resetAt: ms-epoch }. The endpoint reports utilization
215
+ // as a percentage in the 0-100 range, so 1 means 1%, not 100%.
216
+ export function normalizeUsageBucket(bucket) {
217
+ if (!bucket || typeof bucket !== 'object') return null;
218
+
219
+ const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
220
+ const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
221
+ const utilization = Number.isFinite(parsedPct)
222
+ ? parsedPct / 100
223
+ : null;
224
+
225
+ const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
226
+ let resetAt = null;
227
+ if (typeof rawReset === 'number') {
228
+ resetAt = rawReset < 1e12 ? rawReset * 1000 : rawReset;
229
+ } else if (typeof rawReset === 'string') {
230
+ const asNum = Number(rawReset);
231
+ if (Number.isFinite(asNum) && rawReset.trim() !== '') {
232
+ resetAt = asNum < 1e12 ? asNum * 1000 : asNum;
233
+ } else {
234
+ const parsed = Date.parse(rawReset);
235
+ if (Number.isFinite(parsed)) resetAt = parsed;
236
+ }
237
+ }
238
+
239
+ return { utilization, resetAt };
240
+ }
241
+
242
+ /**
243
+ * Fetch OAuth subscription usage from the usage endpoint. This reports quota
244
+ * utilization WITHOUT spending message quota, which is what makes it safe to
245
+ * poll. Returns normalized { fiveHour, sevenDay, sevenDaySonnet, sevenDayFable } buckets, or
246
+ * { error, status } on failure.
247
+ */
248
+ export async function fetchUsage(accessToken) {
249
+ try {
250
+ const res = await proxyFetch(USAGE_URL, {
251
+ headers: {
252
+ 'Authorization': `Bearer ${accessToken}`,
253
+ 'anthropic-beta': OAUTH_USAGE_BETA,
254
+ 'Accept': 'application/json',
255
+ },
256
+ });
257
+
258
+ if (!res.ok) {
259
+ let detail = '';
260
+ try {
261
+ const body = await res.json();
262
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
263
+ } catch {
264
+ detail = await res.text().catch(() => '');
265
+ }
266
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}`, status: res.status };
267
+ }
268
+
269
+ const data = await res.json();
270
+ return {
271
+ fiveHour: normalizeUsageBucket(data?.five_hour),
272
+ sevenDay: normalizeUsageBucket(data?.seven_day),
273
+ sevenDaySonnet: normalizeUsageBucket(data?.seven_day_sonnet),
274
+ sevenDayFable: normalizeUsageBucket(findScopedWeeklyLimit(data, /fable/i)),
275
+ };
276
+ } catch (err) {
277
+ return { error: err.message || String(err), status: null };
278
+ }
279
+ }
280
+
281
+ // OAuth config (extracted from Claude Code). Client id + token endpoint are
282
+ // shared with the refresh path — see DEFAULT_CLIENT_ID / DEFAULT_TOKEN_ENDPOINT.
283
+ const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
284
+ const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
285
+
286
+ /**
287
+ * Perform OAuth login via browser with PKCE flow.
288
+ * Opens the user's browser, waits for the callback, exchanges the code for tokens.
289
+ */
290
+ export async function loginOAuth() {
291
+ // Generate PKCE
292
+ const codeVerifier = randomBytes(32).toString('base64url');
293
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
294
+ const state = randomBytes(32).toString('base64url');
295
+
296
+ // Start local callback server on a random port
297
+ const { port, codePromise, server } = await startCallbackServer(state);
298
+ const redirectUri = `http://localhost:${port}/callback`;
299
+
300
+ // Build authorization URL
301
+ const authUrl = new URL(OAUTH_AUTHORIZE);
302
+ authUrl.searchParams.set('code', 'true');
303
+ authUrl.searchParams.set('client_id', DEFAULT_CLIENT_ID);
304
+ authUrl.searchParams.set('response_type', 'code');
305
+ authUrl.searchParams.set('redirect_uri', redirectUri);
306
+ authUrl.searchParams.set('scope', OAUTH_SCOPES);
307
+ authUrl.searchParams.set('code_challenge', codeChallenge);
308
+ authUrl.searchParams.set('code_challenge_method', 'S256');
309
+ authUrl.searchParams.set('state', state);
310
+
311
+ // Open browser
312
+ console.log('Opening browser for authentication...');
313
+ console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
314
+ openBrowser(authUrl.toString());
315
+
316
+ // Wait for either the callback server or manual paste from stdin
317
+ let code;
318
+ try {
319
+ code = await raceWithStdinCode(codePromise, state);
320
+ } finally {
321
+ server.close();
322
+ }
323
+
324
+ // Exchange code for tokens
325
+ console.log('Exchanging authorization code for tokens...');
326
+ const tokenRes = await proxyFetch(DEFAULT_TOKEN_ENDPOINT, {
327
+ method: 'POST',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: JSON.stringify({
330
+ code,
331
+ state,
332
+ grant_type: 'authorization_code',
333
+ client_id: DEFAULT_CLIENT_ID,
334
+ redirect_uri: redirectUri,
335
+ code_verifier: codeVerifier,
336
+ }),
337
+ });
338
+
339
+ if (!tokenRes.ok) {
340
+ const text = await tokenRes.text();
341
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
342
+ }
343
+
344
+ const tokens = await tokenRes.json();
345
+ return {
346
+ accessToken: tokens.access_token,
347
+ refreshToken: tokens.refresh_token,
348
+ expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Race the callback server promise against manual code entry from stdin.
354
+ * The user can paste the full callback URL or just the authorization code.
355
+ */
356
+ function raceWithStdinCode(callbackPromise, expectedState) {
357
+ if (!process.stdin.isTTY) return callbackPromise;
358
+
359
+ return new Promise((resolve, reject) => {
360
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
361
+ let settled = false;
362
+
363
+ const settle = (fn, val) => {
364
+ if (settled) return;
365
+ settled = true;
366
+ rl.close();
367
+ fn(val);
368
+ };
369
+
370
+ rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
371
+ const trimmed = answer.trim();
372
+ if (!trimmed) return; // empty input, keep waiting for callback
373
+
374
+ // Try to parse as a URL with ?code= parameter
375
+ try {
376
+ const url = new URL(trimmed);
377
+ const code = url.searchParams.get('code');
378
+ const state = url.searchParams.get('state');
379
+ if (code) {
380
+ if (expectedState && state && state !== expectedState) {
381
+ settle(reject, new Error('OAuth state mismatch'));
382
+ } else {
383
+ settle(resolve, code);
384
+ }
385
+ return;
386
+ }
387
+ } catch {}
388
+
389
+ // Treat raw input as the authorization code
390
+ settle(resolve, trimmed);
391
+ });
392
+
393
+ callbackPromise.then(
394
+ code => settle(resolve, code),
395
+ err => settle(reject, err),
396
+ );
397
+ });
398
+ }
399
+
400
+ function startCallbackServer(expectedState) {
401
+ return new Promise((resolve, reject) => {
402
+ let resolveCode, rejectCode;
403
+ const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
404
+
405
+ const server = http.createServer((req, res) => {
406
+ const url = new URL(req.url, `http://localhost`);
407
+
408
+ if (url.pathname === '/callback') {
409
+ const code = url.searchParams.get('code');
410
+ const error = url.searchParams.get('error');
411
+ const state = url.searchParams.get('state');
412
+
413
+ if (error) {
414
+ res.writeHead(200, { 'Content-Type': 'text/html' });
415
+ res.end('<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>');
416
+ rejectCode(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') || ''}`));
417
+ return;
418
+ }
419
+
420
+ if (expectedState && state !== expectedState) {
421
+ res.writeHead(200, { 'Content-Type': 'text/html' });
422
+ res.end('<html><body><h2>Authentication failed</h2><p>State mismatch. You can close this tab.</p></body></html>');
423
+ rejectCode(new Error('OAuth state mismatch'));
424
+ return;
425
+ }
426
+
427
+ if (code) {
428
+ res.writeHead(302, { 'Location': 'https://platform.claude.com/oauth/code/success?app=claude-code' });
429
+ res.end();
430
+ resolveCode(code);
431
+ return;
432
+ }
433
+ }
434
+
435
+ res.writeHead(404);
436
+ res.end('Not found');
437
+ });
438
+
439
+ server.listen(0, () => {
440
+ resolve({ port: server.address().port, codePromise, server });
441
+ });
442
+ server.on('error', reject);
443
+
444
+ // Timeout after 2 minutes (unref so it doesn't keep the process alive)
445
+ const timer = setTimeout(() => {
446
+ rejectCode(new Error('Login timed out after 2 minutes'));
447
+ server.close();
448
+ }, 120_000);
449
+ timer.unref();
450
+ });
451
+ }
452
+
453
+ function openBrowser(url) {
454
+ const platform = process.platform;
455
+ const cmd = platform === 'darwin' ? 'open'
456
+ : platform === 'win32' ? 'start'
457
+ : 'xdg-open';
458
+ exec(`${cmd} ${JSON.stringify(url)}`, () => {});
459
+ }
package/src/prober.js ADDED
@@ -0,0 +1,158 @@
1
+ // Opt-in background quota probe.
2
+ //
3
+ // DISABLED BY DEFAULT. When enabled (config.quotaProbeSeconds > 0), periodically
4
+ // reads an OAuth account's quota zero-spend /api/oauth/usage endpoint so idle
5
+ // accounts' utilization/reset stay fresh without waiting to rotate onto them.
6
+ // A sanctioned active-upstream feature (the other is the opt-in keep-warm
7
+ // scheduler, warmer.js); the proxy is otherwise passive. Unlike keep-warm, this
8
+ // probe reads a zero-spend endpoint and never consumes message quota.
9
+
10
+ import { fetchUsage } from './oauth.js';
11
+
12
+ export class Prober {
13
+ constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, timeoutMs = 10_000, log = console.log } = {}) {
14
+ this.am = accountManager;
15
+ this.intervalMs = intervalMs;
16
+ this.probeFn = probeFn;
17
+ this.timeoutMs = timeoutMs;
18
+ this.log = log;
19
+ this.timer = null;
20
+ this._running = false;
21
+ this.lastRunStartedAt = null;
22
+ this.lastRunFinishedAt = null;
23
+ this.nextRunAt = intervalMs > 0 ? Date.now() + intervalMs : null;
24
+ this.accountStatus = new Map();
25
+ }
26
+
27
+ start() {
28
+ if (this.intervalMs > 0) this.reschedule(this.intervalMs);
29
+ }
30
+
31
+ /** Change interval at runtime (0 = off). Probes once immediately when on. */
32
+ reschedule(intervalMs) {
33
+ const wasOn = this.intervalMs > 0 && this.timer;
34
+ this.intervalMs = intervalMs;
35
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
36
+
37
+ if (intervalMs > 0) {
38
+ this.nextRunAt = Date.now() + intervalMs;
39
+ // Immediate probe only on an off→on transition — not on every interval
40
+ // change (mirrors warmer.js; avoids an extra burst when the interval is edited).
41
+ if (!wasOn) this.probeAll().catch(() => {});
42
+ this.timer = setInterval(() => this.probeAll().catch(() => {}), intervalMs);
43
+ this.timer.unref?.();
44
+ this.log(`[TeamClaude] Quota probe enabled (every ${Math.round(intervalMs / 1000)}s)`);
45
+ } else if (wasOn) {
46
+ this.nextRunAt = null;
47
+ this.log('[TeamClaude] Quota probe disabled');
48
+ }
49
+ }
50
+
51
+ stop() {
52
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
53
+ this.nextRunAt = null;
54
+ }
55
+
56
+ /** Probe every OAuth account once. Overlapping cycles are skipped. */
57
+ async probeAll() {
58
+ if (this._running) return;
59
+ this._running = true;
60
+ this.lastRunStartedAt = Date.now();
61
+ this.nextRunAt = this.intervalMs > 0 ? this.lastRunStartedAt + this.intervalMs : null;
62
+ try {
63
+ const accounts = this.am.accounts.filter(account => account.type === 'oauth' && account.credential);
64
+ await Promise.all(accounts.map(account => this.probeAccount(account)));
65
+ } finally {
66
+ this.lastRunFinishedAt = Date.now();
67
+ this._running = false;
68
+ }
69
+ }
70
+
71
+ async probeAccount(account) {
72
+ const startedAt = Date.now();
73
+ this._recordAccount(account, { status: 'running', startedAt });
74
+ try {
75
+ await this.am.ensureTokenFresh(account.index);
76
+ let usage = await this._withTimeout(this.probeFn(account.credential));
77
+ if (usage?.status === 401) {
78
+ // Token rejected: force refresh and retry once.
79
+ await this.am.ensureTokenFresh(account.index, true);
80
+ usage = await this._withTimeout(this.probeFn(account.credential));
81
+ }
82
+
83
+ if (!usage || usage.error) {
84
+ const finishedAt = Date.now();
85
+ this._recordAccount(account, {
86
+ status: usage?.error ? 'error' : 'timeout',
87
+ error: usage?.error || 'probe timed out',
88
+ startedAt,
89
+ finishedAt,
90
+ durationMs: finishedAt - startedAt,
91
+ });
92
+ return;
93
+ }
94
+
95
+ this.am.applyUsageData(account.index, usage);
96
+ const finishedAt = Date.now();
97
+ this._recordAccount(account, {
98
+ status: 'ok',
99
+ error: null,
100
+ startedAt,
101
+ finishedAt,
102
+ durationMs: finishedAt - startedAt,
103
+ });
104
+ } catch (err) {
105
+ const finishedAt = Date.now();
106
+ this._recordAccount(account, {
107
+ status: 'error',
108
+ error: err?.message || String(err),
109
+ startedAt,
110
+ finishedAt,
111
+ durationMs: finishedAt - startedAt,
112
+ });
113
+ }
114
+ }
115
+
116
+ getStatus() {
117
+ return {
118
+ enabled: this.intervalMs > 0,
119
+ intervalSeconds: Math.round(this.intervalMs / 1000),
120
+ running: this._running,
121
+ lastRunStartedAt: iso(this.lastRunStartedAt),
122
+ lastRunFinishedAt: iso(this.lastRunFinishedAt),
123
+ nextRunAt: iso(this.nextRunAt),
124
+ accounts: this.am.accounts.map(account => {
125
+ const status = this.accountStatus.get(account.name);
126
+ return {
127
+ name: account.name,
128
+ status: account.type === 'oauth' ? (status?.status || 'never') : 'not-applicable',
129
+ lastProbedAt: iso(status?.finishedAt),
130
+ startedAt: iso(status?.startedAt),
131
+ durationMs: status?.durationMs ?? null,
132
+ error: status?.error || null,
133
+ };
134
+ }),
135
+ };
136
+ }
137
+
138
+ _recordAccount(account, status) {
139
+ this.accountStatus.set(account.name, {
140
+ ...(this.accountStatus.get(account.name) || {}),
141
+ ...status,
142
+ });
143
+ }
144
+
145
+ _withTimeout(promise) {
146
+ return Promise.race([
147
+ promise,
148
+ new Promise(resolve => {
149
+ const t = setTimeout(() => resolve(null), this.timeoutMs);
150
+ t.unref?.();
151
+ }),
152
+ ]);
153
+ }
154
+ }
155
+
156
+ function iso(ts) {
157
+ return ts ? new Date(ts).toISOString() : null;
158
+ }
@@ -0,0 +1,32 @@
1
+ // Streaming body writer for the request logger (used by the reverse-proxy /
2
+ // MITM forward path in server.js). JSON bodies are pretty-printed on the fly via
3
+ // a streaming state machine (src/json-format-stream.js) — never buffered whole,
4
+ // so even ~1M-token bodies cost only the current chunk, and a request that
5
+ // blocks mid-stream leaves its partial (readable) body on disk so you can see
6
+ // exactly how far it got. No size caps.
7
+
8
+ import { JsonStreamFormatter } from './json-format-stream.js';
9
+
10
+ // Tracks how one direction's body is written: decide formatter-vs-raw on the
11
+ // first chunk (event-stream → raw; otherwise pretty-print if it looks like JSON,
12
+ // i.e. the first non-whitespace byte is { or [). Writes the section header once.
13
+ export class BodyWriter {
14
+ constructor(write, label, contentType) {
15
+ this.write = write;
16
+ this.label = label;
17
+ this.isStream = /event-stream/.test(contentType);
18
+ this.decided = false;
19
+ this.fmt = null;
20
+ this.headerWritten = false;
21
+ }
22
+ chunk(buf) {
23
+ if (!buf.length) return;
24
+ if (!this.headerWritten) { this.write(`\n\n=== ${this.label} ===\n`); this.headerWritten = true; }
25
+ if (!this.decided) {
26
+ const first = buf.toString('latin1').trimStart()[0];
27
+ if (!this.isStream && (first === '{' || first === '[')) this.fmt = new JsonStreamFormatter();
28
+ this.decided = true;
29
+ }
30
+ this.write(this.fmt ? this.fmt.push(buf) : buf.toString('latin1'));
31
+ }
32
+ }
@@ -0,0 +1,43 @@
1
+ import { importCredentials } from './oauth.js';
2
+
3
+ /**
4
+ * Turn configured accounts into the objects the AccountManager is built from:
5
+ * `importFrom` entries have their credentials read from disk, and entries with
6
+ * no usable credential are dropped with a message.
7
+ *
8
+ * Config fields are carried through verbatim — the import supplies ONLY the
9
+ * credential fields. Rebuilding an imported account as `{ name, type, ...creds }`
10
+ * used to discard everything else on it (`disabled`, `priority`, `upstream`,
11
+ * `modelMap`, `models`), so an account disabled on disk silently rejoined
12
+ * rotation on every restart and a third-party backend lost its upstream.
13
+ */
14
+ export async function resolveAccounts(config) {
15
+ const accounts = [];
16
+ for (const acct of config.accounts) {
17
+ if (acct.type === 'oauth') {
18
+ if (acct.importFrom) {
19
+ try {
20
+ const creds = await importCredentials(acct.importFrom);
21
+ // A readable file with no token is as unusable as a missing one; the
22
+ // non-import branch below already refuses that case, and pushing it
23
+ // anyway would send `Bearer undefined` upstream on every request.
24
+ if (!creds.accessToken) {
25
+ console.error(`No token in ${acct.importFrom} for "${acct.name}", skipping`);
26
+ continue;
27
+ }
28
+ accounts.push({ ...acct, ...creds });
29
+ console.log(`Imported "${acct.name}" from ${acct.importFrom}`);
30
+ } catch (err) {
31
+ console.error(`Failed to import "${acct.name}": ${err.message}`);
32
+ }
33
+ } else if (acct.accessToken) {
34
+ accounts.push(acct);
35
+ } else {
36
+ console.error(`No token for "${acct.name}", skipping`);
37
+ }
38
+ } else if (acct.type === 'apikey' && acct.apiKey) {
39
+ accounts.push(acct);
40
+ }
41
+ }
42
+ return accounts;
43
+ }