@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.
@@ -0,0 +1,63 @@
1
+ // Streaming JSON pretty-printer (no regex, no buffering of the whole body).
2
+ //
3
+ // Built on the same idea as the account_uuid patcher: walk the bytes once,
4
+ // tracking only enough state (nesting depth, in-string, escape) to know where
5
+ // we are, and re-emit with indentation as we go. This lets the request logger
6
+ // flush a readable body to disk *as it streams* — so when a request blocks
7
+ // mid-flight, the partial (pretty) body is already on disk and you can see how
8
+ // far it got. Bodies can be ~1M tokens, so we never hold more than the current
9
+ // chunk.
10
+ //
11
+ // Whitespace outside strings is dropped and re-inserted; strings (including any
12
+ // whitespace/escapes inside them) are copied verbatim. Operates on latin1 so a
13
+ // multi-byte UTF-8 sequence split across chunks is preserved byte-for-byte.
14
+ export class JsonStreamFormatter {
15
+ constructor(indent = 2) {
16
+ this.pad = ' '.repeat(indent);
17
+ this.depth = 0;
18
+ this.inStr = false;
19
+ this.esc = false;
20
+ this.freshContainer = false; // just opened { or [ — first element needs a newline+indent
21
+ }
22
+
23
+ nl(depth) { return '\n' + this.pad.repeat(depth); }
24
+
25
+ // Feed a chunk; returns the formatted text for that chunk.
26
+ push(buf) {
27
+ const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
28
+ let out = '';
29
+ for (let i = 0; i < text.length; i++) {
30
+ const ch = text[i];
31
+
32
+ if (this.inStr) {
33
+ out += ch;
34
+ if (this.esc) this.esc = false;
35
+ else if (ch === '\\') this.esc = true;
36
+ else if (ch === '"') this.inStr = false;
37
+ continue;
38
+ }
39
+
40
+ // Outside a string: collapse existing whitespace; we re-insert our own.
41
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
42
+
43
+ if (ch === '}' || ch === ']') {
44
+ this.depth--;
45
+ // Empty container: emit "{}" / "[]" with no inner newline.
46
+ if (this.freshContainer) { this.freshContainer = false; out += ch; }
47
+ else out += this.nl(this.depth) + ch;
48
+ continue;
49
+ }
50
+
51
+ // Any other token. If it's the first token inside a just-opened
52
+ // container, break the line and indent first.
53
+ if (this.freshContainer) { out += this.nl(this.depth); this.freshContainer = false; }
54
+
55
+ if (ch === '{' || ch === '[') { out += ch; this.depth++; this.freshContainer = true; continue; }
56
+ if (ch === ',') { out += ',' + this.nl(this.depth); continue; }
57
+ if (ch === ':') { out += ': '; continue; }
58
+ if (ch === '"') { this.inStr = true; out += ch; continue; }
59
+ out += ch; // number / true / false / null character
60
+ }
61
+ return out;
62
+ }
63
+ }
package/src/mitm.js ADDED
@@ -0,0 +1,336 @@
1
+ // MITM forward-proxy support: local cert lifecycle + terminating CONNECT proxy.
2
+ //
3
+ // When a claude instance is launched with HTTPS_PROXY pointed at teamclaude it
4
+ // sends `CONNECT api.anthropic.com:443`. Rather than byte-relaying the tunnel, we
5
+ // TERMINATE it with a real Node HTTP/2 server (allowHTTP1, so an h1 client works
6
+ // too) presenting our locally-minted leaf, then forward each request with a
7
+ // buffering, retrying client — the SAME path the base proxy uses
8
+ // (createProxyRequestListener). That gives per-request account selection, body
9
+ // account_uuid rewriting, and — critically — the ability to resend a request on a
10
+ // different account when one returns a quota 429, instead of surfacing it. A host
11
+ // routing table decides per-CONNECT behavior:
12
+ // api.anthropic.com → terminate + forward, www.example.org → local test server,
13
+ // anything else → blind tunnel.
14
+
15
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
16
+ import { X509Certificate } from 'node:crypto';
17
+ import { dirname, join } from 'node:path';
18
+ import net from 'node:net';
19
+ import tls from 'node:tls';
20
+ import http2 from 'node:http2';
21
+ import { getConfigPath } from './config.js';
22
+ import { generateCertChain } from './x509.js';
23
+ import { createProxyRequestListener, safeKeyEqual, isLoopbackAddr, relayUpgrade, resolveAccountPin } from './server.js';
24
+
25
+ const CA_CERT = 'teamclaude-ca.pem';
26
+ const LEAF_CERT = 'teamclaude-leaf.pem';
27
+ const LEAF_KEY = 'teamclaude-leaf.key';
28
+
29
+ // A built-in host the MITM proxy always intercepts and answers itself (never
30
+ // forwarded upstream). Lets you verify the proxy + CA end-to-end with no
31
+ // credentials, e.g.:
32
+ // curl --proxy http://localhost:3456 --cacert <ca.pem> https://www.example.org/
33
+ export const TEST_HOST = 'www.example.org';
34
+
35
+ const certDir = () => dirname(getConfigPath());
36
+ const fpath = (n) => join(certDir(), n);
37
+
38
+ /** Path to the CA cert clients should trust via NODE_EXTRA_CA_CERTS. */
39
+ export function caCertPath() {
40
+ return fpath(CA_CERT);
41
+ }
42
+
43
+ async function readIf(p) {
44
+ try { return await readFile(p, 'utf8'); } catch { return null; }
45
+ }
46
+
47
+ async function atomicWrite(path, data, mode) {
48
+ const tmp = `${path}.tmp${process.pid}`;
49
+ await writeFile(tmp, data, { mode });
50
+ await rename(tmp, path);
51
+ }
52
+
53
+ // Is the stored leaf signed by the stored CA and valid for every host in `hosts`?
54
+ function leafCovers(caCertPem, leafCertPem, hosts) {
55
+ try {
56
+ const ca = new X509Certificate(caCertPem);
57
+ const leaf = new X509Certificate(leafCertPem);
58
+ if (!leaf.verify(ca.publicKey)) return false;
59
+ const names = (leaf.subjectAltName || '').split(',').map((s) => s.trim());
60
+ return hosts.every((h) => names.includes(`DNS:${h}`));
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Ensure a CA cert + a leaf for `host` exist in the config dir, generating them
68
+ * if missing/mismatched. The CA *private* key is never persisted — we regenerate
69
+ * the whole chain when needed, so the only on-disk secret is the leaf key (0600),
70
+ * which only authenticates as `host` to a process that already trusts our CA.
71
+ * Returns { caPath, caCertPem, leafCertPem, leafKeyPem }.
72
+ */
73
+ export async function ensureCerts(host) {
74
+ const hosts = host === TEST_HOST ? [TEST_HOST] : [host, TEST_HOST];
75
+ const [caCertPem, leafCertPem, leafKeyPem] = await Promise.all([
76
+ readIf(fpath(CA_CERT)), readIf(fpath(LEAF_CERT)), readIf(fpath(LEAF_KEY)),
77
+ ]);
78
+
79
+ if (caCertPem && leafCertPem && leafKeyPem && leafCovers(caCertPem, leafCertPem, hosts)) {
80
+ return { caPath: fpath(CA_CERT), caCertPem, leafCertPem, leafKeyPem };
81
+ }
82
+
83
+ const chain = generateCertChain(hosts); // caKeyPem intentionally discarded
84
+ await mkdir(certDir(), { recursive: true });
85
+ await atomicWrite(fpath(CA_CERT), chain.caCertPem, 0o644);
86
+ await atomicWrite(fpath(LEAF_CERT), chain.leafCertPem, 0o644);
87
+ await atomicWrite(fpath(LEAF_KEY), chain.leafKeyPem, 0o600);
88
+ return {
89
+ caPath: fpath(CA_CERT),
90
+ caCertPem: chain.caCertPem,
91
+ leafCertPem: chain.leafCertPem,
92
+ leafKeyPem: chain.leafKeyPem,
93
+ };
94
+ }
95
+
96
+ function upstreamHostOf(config) {
97
+ try { return new URL(config?.upstream || 'https://api.anthropic.com').hostname; }
98
+ catch { return 'api.anthropic.com'; }
99
+ }
100
+
101
+ /** Per-CONNECT behavior: 'rewrite' (intercept + token inject), 'test', or 'tunnel'. */
102
+ export function hostMode(host, config) {
103
+ if (host === TEST_HOST) return 'test';
104
+ if (host === upstreamHostOf(config)) return 'rewrite';
105
+ return 'tunnel';
106
+ }
107
+
108
+ /**
109
+ * Build a `connect` event handler implementing the terminating MITM described at
110
+ * the top of this file.
111
+ * @param ensureLeaf async () => { key, cert } // current leaf PEMs
112
+ */
113
+ export function createConnectHandler({ config, accountManager, ensureLeaf, logDir = null, hooks = {}, log = () => {}, sx = null, egress = null }) {
114
+ const upstream = config.upstream || 'https://api.anthropic.com';
115
+ const proxyApiKey = config.proxy?.apiKey;
116
+ const holdMs = (config.holdSeconds || 0) * 1000;
117
+
118
+ // One terminating h2/h1 server per pin, minted lazily on the first intercepted
119
+ // CONNECT that needs it (key '' = unpinned, the common case).
120
+ // TLS uses our leaf; ALPN negotiates h2 or http/1.1 (allowHTTP1) with whatever
121
+ // the client offers. It emits 'request' for BOTH protocols, so `forward` — the
122
+ // shared buffering/retrying proxy listener — handles them identically. Each
123
+ // CONNECT feeds it the raw tunnel socket; the client keeps the tunnel open and
124
+ // multiplexes many requests over it, each independently account-selected.
125
+ //
126
+ // Keying by pin is what carries a TC_ACCT pin from the CONNECT to the requests
127
+ // inside the tunnel. The alternative — tagging the raw socket and reading it
128
+ // back from the request — means digging through a TLSSocket and, under h2, a
129
+ // Proxy over the session socket. A listener bound to the account is the same
130
+ // information with none of that. The map is bounded by the account count.
131
+ const serverPromises = new Map();
132
+ const getServer = (pin = '') => {
133
+ let p = serverPromises.get(pin);
134
+ if (p) return p;
135
+ p = (async () => {
136
+ const { key, cert } = await ensureLeaf();
137
+ const srv = http2.createSecureServer({ key, cert, allowHTTP1: true });
138
+ srv.on('request', createProxyRequestListener({ accountManager, upstream, logDir, hooks, sx, holdMs, config, forcedPin: pin || null, egress }));
139
+ // Remote Control's real-time channel is a WebSocket (Upgrade handshake),
140
+ // which never fires 'request' — only 'upgrade', with a raw socket instead
141
+ // of a response object (h1-only; falls back to blind h2 passthrough is not
142
+ // needed since WS clients negotiate h1 for the handshake).
143
+ srv.on('upgrade', (req, socket, head) => relayUpgrade(req, socket, head, upstream, sx));
144
+ srv.on('sessionError', (e) => log(`[TeamClaude] MITM session error: ${e.message}`));
145
+ srv.on('clientError', (e, sock) => { try { sock.destroy(); } catch { /* already gone */ } });
146
+ return srv;
147
+ })().catch((err) => {
148
+ // Don't let a transient cert/disk failure poison the memo forever: drop it
149
+ // so the next intercepted CONNECT retries instead of re-awaiting a cached
150
+ // rejection (which would leave the MITM path dead until a restart).
151
+ serverPromises.delete(pin);
152
+ throw err;
153
+ });
154
+ serverPromises.set(pin, p);
155
+ return p;
156
+ };
157
+
158
+ return (req, clientSocket, head) => {
159
+ clientSocket.on('error', () => {});
160
+
161
+ // Auth gate — mirror the HTTP path: loopback is exempt, everything else must
162
+ // present the proxy apiKey via Proxy-Authorization. Without this, a remote
163
+ // client can CONNECT api.anthropic.com and have a rotated ACCOUNT TOKEN
164
+ // injected (token theft), or blind-tunnel to arbitrary hosts (open relay /
165
+ // SSRF) — the HTTP path already blocks the equivalent for remote clients.
166
+ if (!connectAuthorized(req, clientSocket, proxyApiKey)) {
167
+ try {
168
+ clientSocket.write('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="teamclaude"\r\nConnection: close\r\n\r\n');
169
+ } catch { /* client already gone */ }
170
+ clientSocket.destroy();
171
+ return;
172
+ }
173
+
174
+ const [host, portStr] = (req.url || '').split(':');
175
+ const port = parseInt(portStr, 10) || 443;
176
+ const mode = hostMode(host, config);
177
+
178
+ if (mode === 'tunnel') {
179
+ // Until the upstream connects we still owe the client a CONNECT status
180
+ // line. If we tore the socket down on an upstream failure without one,
181
+ // the client reports "Proxy connection ended before receiving CONNECT
182
+ // response" — so before the tunnel is live, surface failures as a real
183
+ // proxy error status instead of a silent drop.
184
+ let established = false, closed = false;
185
+ // Tear down BOTH sockets when either errors or closes, so a one-sided
186
+ // failure can't leave the paired socket lingering (FD leak). The `closed`
187
+ // guard makes it idempotent (error+close both fire) and ensures we write
188
+ // at most one status line.
189
+ const teardown = (statusLine) => {
190
+ if (closed) return;
191
+ closed = true;
192
+ if (!established && statusLine) {
193
+ try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`); } catch { /* client already gone */ }
194
+ }
195
+ up.destroy(); clientSocket.destroy();
196
+ };
197
+ const up = net.connect(port, host, () => {
198
+ established = true;
199
+ reply200Raw(clientSocket);
200
+ if (head && head.length) up.write(head);
201
+ up.pipe(clientSocket); clientSocket.pipe(up);
202
+ });
203
+ up.on('error', (err) => {
204
+ if (!established) log(`[TeamClaude] tunnel ${host}:${port} failed: ${err.message}`);
205
+ teardown('502 Bad Gateway');
206
+ });
207
+ // A FIN before the tunnel is live (no preceding 'error') is still a failed
208
+ // dial from the client's view — surface a 502 rather than a silent drop.
209
+ up.on('close', () => teardown('502 Bad Gateway'));
210
+ clientSocket.on('close', () => teardown()); // client gone: nothing to write
211
+ up.setTimeout(30_000, () => teardown('504 Gateway Timeout')); // bound a stalled connect/idle tunnel
212
+ return;
213
+ }
214
+
215
+ if (mode === 'test') {
216
+ // The built-in test host is answered locally, never forwarded upstream.
217
+ ensureLeaf().then(({ key, cert }) => {
218
+ reply200Raw(clientSocket);
219
+ serveTest(termClaude(clientSocket, head, key, cert, ['http/1.1']));
220
+ }).catch((err) => { log(`[TeamClaude] MITM ${host}: ${err.message}`); reply502Raw(clientSocket); clientSocket.destroy(); });
221
+ return;
222
+ }
223
+
224
+ // rewrite: terminate the tunnel and forward each request with buffering +
225
+ // retry. Reply 200, hand the raw socket (ClientHello and all) to the h2/h1
226
+ // server, which does TLS + protocol negotiation itself. If the terminating
227
+ // server can't be minted (cert/disk/TLS-init failure) we haven't replied yet
228
+ // — send a 502 so the client sees a real proxy error instead of "Proxy
229
+ // connection ended before receiving CONNECT response".
230
+ // Pin resolution is deliberately confined to `rewrite`. Clients send
231
+ // Proxy-Authorization on EVERY CONNECT, including blind-tunneled third-party
232
+ // hosts, where an account pin is meaningless — rejecting there would take
233
+ // down unrelated traffic over a typo meant for Anthropic.
234
+ const { pin, error } = resolveConnectPin(req, accountManager, proxyApiKey);
235
+ if (error) {
236
+ log(`[TeamClaude] CONNECT ${host}: ${error}`);
237
+ try {
238
+ clientSocket.write(`HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="teamclaude"\r\nConnection: close\r\n\r\n`);
239
+ } catch { /* client already gone */ }
240
+ clientSocket.destroy();
241
+ return;
242
+ }
243
+
244
+ getServer(pin || '').then((srv) => {
245
+ reply200Raw(clientSocket);
246
+ if (head && head.length) clientSocket.unshift(head);
247
+ srv.emit('connection', clientSocket);
248
+ }).catch((err) => { log(`[TeamClaude] MITM ${host}: ${err.message}`); reply502Raw(clientSocket); clientSocket.destroy(); });
249
+ };
250
+ }
251
+
252
+ // The Basic username from a CONNECT's `Proxy-Authorization`, or null. This is
253
+ // the only pin channel expressible in an HTTPS_PROXY URL, which is what
254
+ // `teamclaude run` has to work with in MITM mode (there is no request path to
255
+ // carry a `/tc-acct/` prefix — inside the tunnel the path is the real upstream
256
+ // one). Clients send this preemptively on every CONNECT.
257
+ export function connectPinToken(req) {
258
+ const header = (req?.headers?.['proxy-authorization'] || '').trim();
259
+ if (!header.toLowerCase().startsWith('basic ')) return null;
260
+ const dec = Buffer.from(header.slice('basic '.length).trim(), 'base64').toString('utf8'); // "user:pass"
261
+ const colon = dec.indexOf(':');
262
+ return (colon >= 0 ? dec.slice(0, colon) : dec) || null;
263
+ }
264
+
265
+ /**
266
+ * Resolve the account pin on a CONNECT, or a rejection reason.
267
+ *
268
+ * The username slot is overloaded: the documented remote form is
269
+ * `--proxy http://<key>@host:port`, where it holds the proxy apiKey, not an
270
+ * account. So the key wins over any account of the same name — an operator who
271
+ * names an account after their proxy key gets auth, not a surprise pin.
272
+ *
273
+ * An unrecognized username is an ERROR rather than a silently ignored pin: a
274
+ * typo'd account name that quietly served from the wrong account is exactly the
275
+ * failure mode this feature exists to remove.
276
+ *
277
+ * @returns {{pin: string|null, error: string|null}}
278
+ */
279
+ export function resolveConnectPin(req, accountManager, proxyApiKey) {
280
+ const token = connectPinToken(req);
281
+ if (!token) return { pin: null, error: null };
282
+ if (proxyApiKey && safeKeyEqual(token, proxyApiKey)) return { pin: null, error: null };
283
+ if (resolveAccountPin(accountManager, token) == null) {
284
+ return { pin: null, error: `Unknown account pin "${token}"` };
285
+ }
286
+ return { pin: token, error: null };
287
+ }
288
+
289
+ // Authorize a CONNECT: no key configured → open (matches the HTTP path); a
290
+ // loopback client is exempt; otherwise the proxy apiKey must be presented via
291
+ // `Proxy-Authorization` (Bearer <key>, or Basic where the key is the username
292
+ // or password — so `--proxy http://<key>@host:port` works). Exported for tests.
293
+ export function connectAuthorized(req, socket, proxyApiKey) {
294
+ if (!proxyApiKey) return true;
295
+ if (isLoopbackAddr(socket?.remoteAddress)) return true;
296
+ const m = /^\s*(basic|bearer)\s+(.+?)\s*$/i.exec(req?.headers?.['proxy-authorization'] || '');
297
+ if (!m) return false;
298
+ let presented = m[2];
299
+ if (m[1].toLowerCase() === 'basic') {
300
+ const dec = Buffer.from(m[2], 'base64').toString('utf8'); // "user:pass"
301
+ const i = dec.indexOf(':');
302
+ const user = i >= 0 ? dec.slice(0, i) : dec;
303
+ const pass = i >= 0 ? dec.slice(i + 1) : '';
304
+ presented = pass || user;
305
+ }
306
+ return safeKeyEqual(presented, proxyApiKey);
307
+ }
308
+
309
+ function reply200Raw(sock) { sock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); }
310
+ function reply502Raw(sock) { try { sock.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); } catch { /* client already gone */ } }
311
+
312
+ function termClaude(clientSocket, head, key, cert, alpn) {
313
+ if (head && head.length) clientSocket.unshift(head);
314
+ const t = new tls.TLSSocket(clientSocket, { isServer: true, key, cert, ALPNProtocols: alpn });
315
+ t.on('error', () => t.destroy());
316
+ return t;
317
+ }
318
+
319
+ // Answer the built-in test host locally over h1 with a canned JSON response.
320
+ function serveTest(tlsSock) {
321
+ let buf = Buffer.alloc(0);
322
+ const onData = (chunk) => {
323
+ buf = Buffer.concat([buf, chunk]);
324
+ const idx = buf.indexOf('\r\n\r\n');
325
+ if (idx < 0) { if (buf.length > 65536) tlsSock.destroy(); return; }
326
+ tlsSock.removeListener('data', onData);
327
+ const reqLine = buf.subarray(0, buf.indexOf('\r\n')).toString('latin1');
328
+ const path = reqLine.split(' ')[1] || '/';
329
+ const body = JSON.stringify({ teamclaude: 'mitm-proxy-ok', host: TEST_HOST, path });
330
+ tlsSock.end(
331
+ `HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`,
332
+ );
333
+ };
334
+ tlsSock.on('data', onData);
335
+ tlsSock.on('error', () => tlsSock.destroy());
336
+ }
package/src/model.js ADDED
@@ -0,0 +1,276 @@
1
+ // Model-id helpers shared by the request path (server + MITM relay) and account
2
+ // selection. Kept dependency-free so the low-level h2/h1 relay can peek a
3
+ // request's model without pulling in the account-manager graph.
4
+
5
+ // A request targets the Fable model family when its `model` id names Fable
6
+ // (e.g. "claude-fable-5"). Account selection uses this to gate the Fable-only
7
+ // weekly bucket: a Fable-exhausted account still serves every other model.
8
+ export function isFableModel(model) {
9
+ return typeof model === 'string' && /fable/i.test(model);
10
+ }
11
+
12
+ // The model "family" a request belongs to. Anthropic meters some families with
13
+ // their own weekly quota bucket (Fable, Sonnet) on top of the shared 5-hour and
14
+ // weekly buckets, so the family decides which bucket governs a given request —
15
+ // letting an account whose Fable bucket is spent keep serving Opus/Sonnet.
16
+ // Returns a stable lowercase tag; unknown ids fall back to 'other'.
17
+ export function modelFamily(model) {
18
+ if (typeof model !== 'string' || !model) return 'other';
19
+ if (/fable/i.test(model)) return 'fable';
20
+ if (/sonnet/i.test(model)) return 'sonnet';
21
+ if (/opus/i.test(model)) return 'opus';
22
+ if (/haiku/i.test(model)) return 'haiku';
23
+ return 'other';
24
+ }
25
+
26
+ // Quota buckets on an account (see AccountManager emptyQuota). The shared 5-hour
27
+ // bucket applies to every request; the weekly bucket depends on the family.
28
+ // A family with no dedicated weekly bucket falls back to the shared 'unified7d'.
29
+ const FAMILY_WEEKLY_BUCKET = {
30
+ fable: 'unified7dFable',
31
+ sonnet: 'unified7dSonnet',
32
+ };
33
+
34
+ // The weekly quota bucket key that governs a model, e.g. a Fable request is
35
+ // gated by 'unified7dFable' rather than the shared 'unified7d'. Used by account
36
+ // selection so a spent family bucket only bars that family's requests.
37
+ export function weeklyBucketForModel(model) {
38
+ return FAMILY_WEEKLY_BUCKET[modelFamily(model)] || 'unified7d';
39
+ }
40
+
41
+ // Match a shell-style glob against a model id. Only `*` is special (matches any
42
+ // run of characters, including none); every other character is literal. The
43
+ // comparison is case-insensitive. Used by configurable routes so a pattern like
44
+ // `*fable*` or `claude-opus-*` selects the models a route handles.
45
+ export function modelGlobMatches(glob, model) {
46
+ if (typeof glob !== 'string' || typeof model !== 'string') return false;
47
+ const re = '^' + glob.split('*').map(escapeRegExp).join('.*') + '$';
48
+ return new RegExp(re, 'i').test(model);
49
+ }
50
+
51
+ function escapeRegExp(s) {
52
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ }
54
+
55
+ // Do two model globs describe any model in common? Used to tell whether a route
56
+ // is fully shadowed by the blocklist. Exact glob intersection is not decidable
57
+ // in general, so this compares literal cores (the pattern with `*` removed) in
58
+ // both directions: `claude-fable-5` overlaps `*fable*`, and a bare `*` (empty
59
+ // core) overlaps everything. Display-only, and deliberately inclusive — the
60
+ // authoritative per-request gate still matches the concrete model id.
61
+ export function modelGlobOverlaps(a, b) {
62
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
63
+ const core = s => s.replace(/\*/g, '').toLowerCase();
64
+ const ca = core(a);
65
+ const cb = core(b);
66
+ return ca.includes(cb) || cb.includes(ca);
67
+ }
68
+
69
+ // The `blockedModels` pattern that takes a model FAMILY out of service, or null.
70
+ //
71
+ // The blocklist is written against concrete model ids (`*fable*`,
72
+ // `claude-fable-5`) but the status view reasons in families (`Fable`), so a
73
+ // direct glob match is not enough: `claude-fable-5` never matches the literal
74
+ // string `Fable`. Both spellings are checked so the two natural ways to block a
75
+ // family light up the same row — the glob (via modelGlobMatches, which also
76
+ // makes a bare `*` block everything) and a concrete id (via substring).
77
+ //
78
+ // Deliberately advisory: this drives display only. The authoritative gate is the
79
+ // per-request check in server.js, which matches the real model id. A pattern
80
+ // that names no family (say `claude-3-*`) simply lights up no row, and the
81
+ // header list still shows it verbatim.
82
+ export function findFamilyBlock(patterns, family) {
83
+ if (!Array.isArray(patterns) || !family) return null;
84
+ const key = String(family).toLowerCase();
85
+ return patterns.find(p => typeof p === 'string'
86
+ && (modelGlobMatches(p, key) || p.toLowerCase().includes(key))) || null;
87
+ }
88
+
89
+ // Streaming, byte-exact locator for a TOP-LEVEL string field of a JSON object,
90
+ // fed incrementally. It tracks JSON structure (container stack, key/value,
91
+ // string/escape) so it ONLY matches the field at depth 1 of the root object —
92
+ // a `"model": "..."` sitting inside conversation text (a message, a tool result)
93
+ // is nested deeper and is never mistaken for the real field. No regex, no
94
+ // whole-body buffering, so the relay can peek just the first frames.
95
+ export class TopLevelFieldFinder {
96
+ constructor(field) {
97
+ this.field = field; // target key at the root, e.g. 'model'
98
+ this.isObj = []; // container stack: true=object, false=array
99
+ this.awaitingKey = false; // at an object, the next string is a key
100
+ this.inStr = false;
101
+ this.esc = false;
102
+ this.readingKey = false;
103
+ this.readingValue = false; // accumulating the target field's value
104
+ this.curKey = null; // last key seen in the current object
105
+ this.buf = []; // key/value byte accumulation
106
+ this.value = null; // the found value, or null
107
+ this.done = false; // found it, or the root object closed without it
108
+ }
109
+
110
+ /** Feed a chunk (Buffer). Returns the found value so far (string) or null. */
111
+ push(chunk) {
112
+ if (this.done) return this.value;
113
+ for (let i = 0; i < chunk.length && !this.done; i++) this.#byte(chunk[i]);
114
+ return this.value;
115
+ }
116
+
117
+ #atRoot() { return this.isObj.length === 1 && this.isObj[0] === true; }
118
+
119
+ #byte(b) {
120
+ if (this.inStr) {
121
+ if (this.esc) { this.esc = false; if (this.readingKey || this.readingValue) this.buf.push(b); return; }
122
+ if (b === 0x5c) { this.esc = true; if (this.readingKey || this.readingValue) this.buf.push(b); return; } // backslash
123
+ if (b === 0x22) { // closing quote
124
+ this.inStr = false;
125
+ if (this.readingKey) {
126
+ this.curKey = Buffer.from(this.buf).toString('utf8'); this.buf = []; this.readingKey = false;
127
+ } else if (this.readingValue) {
128
+ this.value = Buffer.from(this.buf).toString('utf8'); this.buf = [];
129
+ this.readingValue = false; this.done = true; // the one top-level field we want
130
+ }
131
+ return;
132
+ }
133
+ if (this.readingKey || this.readingValue) this.buf.push(b);
134
+ return;
135
+ }
136
+
137
+ switch (b) {
138
+ case 0x7b: this.isObj.push(true); this.awaitingKey = true; this.curKey = null; break; // {
139
+ case 0x5b: this.isObj.push(false); this.awaitingKey = false; break; // [
140
+ case 0x7d: case 0x5d: // } ]
141
+ this.isObj.pop(); this.curKey = null;
142
+ if (this.isObj.length === 0) this.done = true; // root closed → field absent
143
+ break;
144
+ case 0x3a: this.awaitingKey = false; break; // :
145
+ case 0x2c: this.awaitingKey = this.isObj[this.isObj.length - 1] === true; break; // ,
146
+ case 0x22: // string begins
147
+ if (this.awaitingKey && this.isObj[this.isObj.length - 1]) {
148
+ this.readingKey = true; this.buf = [];
149
+ } else if (this.#atRoot() && this.curKey === this.field) {
150
+ this.readingValue = true; this.buf = [];
151
+ }
152
+ this.inStr = true; this.esc = false;
153
+ break;
154
+ default: break; // scalars / whitespace
155
+ }
156
+ }
157
+ }
158
+
159
+ // Extract the requested model id from a JSON request body (Buffer or string).
160
+ // Uses the streaming top-level finder so it is exact (never matches a `model`
161
+ // key nested in conversation content) and cheap on large bodies (it stops as
162
+ // soon as the top-level field resolves). Returns null if absent.
163
+ export function parseRequestModel(body) {
164
+ if (!body) return null;
165
+ try {
166
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
167
+ return new TopLevelFieldFinder('model').push(buf);
168
+ } catch { return null; }
169
+ }
170
+
171
+ // Byte-exact locator for the SECOND model an advisor request carries: Claude
172
+ // Code's advisor tool (`anthropic-beta: advisor-tool-…`) keeps the executor in
173
+ // the top-level `model` field and nests the advisor's model inside the tools
174
+ // array — `tools: [{ type: "advisor_20260301", name: "advisor", model: "…" }]`.
175
+ // The advisor sub-inference runs on the same account and spends that model's
176
+ // quota bucket, so account selection must see it (issue #98).
177
+ //
178
+ // Same byte-machine discipline as TopLevelFieldFinder: it walks the container
179
+ // stack and only reads `type`/`model` strings that are DIRECT fields of an
180
+ // object element of the ROOT object's `tools` array — a "model" inside a tool's
181
+ // input_schema or inside conversation text is deeper (or under another root
182
+ // key) and never matches. Elements are judged when they close, so field order
183
+ // within the tool object doesn't matter.
184
+ export class AdvisorModelFinder {
185
+ constructor() {
186
+ this.stack = []; // frames: {isObj, key, awaitingKey}
187
+ this.inStr = false;
188
+ this.esc = false;
189
+ this.reading = null; // 'key' | 'type' | 'model' while in a string
190
+ this.buf = [];
191
+ this.toolType = null; // fields of the tools[] element being read
192
+ this.toolModel = null;
193
+ this.value = null; // the advisor model, once found
194
+ this.done = false;
195
+ }
196
+
197
+ /** Feed a chunk (Buffer). Returns the found value so far (string) or null. */
198
+ push(chunk) {
199
+ if (this.done) return this.value;
200
+ for (let i = 0; i < chunk.length && !this.done; i++) this.#byte(chunk[i]);
201
+ return this.value;
202
+ }
203
+
204
+ // The stack is exactly [root object (last key "tools"), array, element object].
205
+ #inToolElement() {
206
+ const s = this.stack;
207
+ return s.length === 3 && s[0].isObj && s[0].key === 'tools' && !s[1].isObj && s[2].isObj;
208
+ }
209
+
210
+ #byte(b) {
211
+ if (this.inStr) {
212
+ if (this.esc) { this.esc = false; if (this.reading) this.buf.push(b); return; }
213
+ if (b === 0x5c) { this.esc = true; if (this.reading) this.buf.push(b); return; } // backslash
214
+ if (b === 0x22) { // closing quote
215
+ this.inStr = false;
216
+ if (this.reading) {
217
+ const text = Buffer.from(this.buf).toString('utf8');
218
+ if (this.reading === 'key') this.stack[this.stack.length - 1].key = text;
219
+ else if (this.reading === 'type') this.toolType = text;
220
+ else this.toolModel = text;
221
+ this.reading = null;
222
+ this.buf = [];
223
+ }
224
+ return;
225
+ }
226
+ if (this.reading) this.buf.push(b);
227
+ return;
228
+ }
229
+
230
+ switch (b) {
231
+ case 0x7b: // {
232
+ this.stack.push({ isObj: true, key: null, awaitingKey: true });
233
+ if (this.#inToolElement()) { this.toolType = null; this.toolModel = null; }
234
+ break;
235
+ case 0x5b: this.stack.push({ isObj: false, key: null, awaitingKey: false }); break; // [
236
+ case 0x7d: // }
237
+ if (this.#inToolElement()
238
+ && typeof this.toolType === 'string' && /^advisor/i.test(this.toolType)
239
+ && this.toolModel) {
240
+ this.value = this.toolModel;
241
+ this.done = true;
242
+ }
243
+ // fall through: pop like ]
244
+ case 0x5d: // ]
245
+ this.stack.pop();
246
+ if (this.stack.length === 0) this.done = true; // root closed → absent
247
+ break;
248
+ case 0x3a: { const t = this.stack[this.stack.length - 1]; if (t?.isObj) t.awaitingKey = false; break; } // :
249
+ case 0x2c: { const t = this.stack[this.stack.length - 1]; if (t?.isObj) t.awaitingKey = true; break; } // ,
250
+ case 0x22: { // string begins
251
+ const t = this.stack[this.stack.length - 1];
252
+ if (t?.isObj && t.awaitingKey) this.reading = 'key';
253
+ else if (this.#inToolElement() && (t.key === 'type' || t.key === 'model')) this.reading = t.key;
254
+ else this.reading = null; // uninteresting string: skip bytes
255
+ this.buf = [];
256
+ this.inStr = true;
257
+ this.esc = false;
258
+ break;
259
+ }
260
+ default: break; // scalars / whitespace
261
+ }
262
+ }
263
+ }
264
+
265
+ // Extract the advisor model from a JSON request body, or null when the request
266
+ // carries no advisor tool. Gated on a cheap byte search for "advisor" so the
267
+ // full structural scan only runs on bodies that could possibly contain one —
268
+ // for everything else this is a single Buffer.includes.
269
+ export function parseAdvisorModel(body) {
270
+ if (!body) return null;
271
+ try {
272
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
273
+ if (!buf.includes('advisor')) return null;
274
+ return new AdvisorModelFinder().push(buf);
275
+ } catch { return null; }
276
+ }