pi-crew 0.11.30 → 0.11.35

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,553 @@
1
+ /**
2
+ * inference-broker.ts — v0.11.31 (S-broker PoC): loopback inference broker.
3
+ *
4
+ * WHY (the A7 endgame continuation): S\* (v0.11.30) made the child env EXACT
5
+ * the intended allowlisted set, but the intended set still must include a
6
+ * credential the child can USE to call its LLM — and any real provider key
7
+ * in the child is a reusable secret a prompt-injected worker could exfiltrate.
8
+ * The broker removes that requirement entirely: the child's ONLY credential
9
+ * is a per-worker token valid exclusively at this process's loopback proxy,
10
+ * which substitutes the real upstream key after the request leaves the
11
+ * worker. The real key never exists in any surface the worker can reach —
12
+ * env (S\* shadows it), auth.json (scoped agent dir has none), or process
13
+ * memory. Same pattern Docker Sandboxes/OpenShell sell as "credential
14
+ * substitution", self-hosted in-process (zero infrastructure).
15
+ *
16
+ * Model routing: the scoped agent dir's models.json overrides ONLY the
17
+ * built-in "anthropic" provider endpoint + apiKey (pi preserves built-in
18
+ * model metadata when a models.json provider entry supplies just baseUrl —
19
+ * docs/models.md "Configure a compatible endpoint"), so `--model
20
+ * anthropic/<id>` and bare `<id>` resolve exactly as before, but every
21
+ * request goes to 127.0.0.1:<port> with `x-api-key: $PI_CREW_INFERENCE_TOKEN`
22
+ * interpolated from the CHILD's env at request time. No model metadata is
23
+ * mirrored, no ids are rewritten, fallback chains inside anthropic work.
24
+ *
25
+ * Credential sources (parent side): ANTHROPIC_API_KEY (x-api-key upstream)
26
+ * or ANTHROPIC_AUTH_TOKEN (Authorization: Bearer upstream) — the documented
27
+ * env-var credential path (models.md "provide an API key through the
28
+ * provider's environment variable… useful in CI"). auth.json-only users are
29
+ * NOT supported by this PoC (reading the parent's auth.json into the broker
30
+ * is a separate, sensitive workstream). FAIL-CLOSED: PI_CREW_INFERENCE_BROKER=1
31
+ * with neither env var present THROWS at the first spawn — never silently
32
+ * falls back to passing real keys to workers.
33
+ *
34
+ * Enforcement: per-worker random 256-bit token; per-worker request budget
35
+ * (PI_CREW_INFERENCE_BUDGET_TOKENS, 0 = unlimited) counted from streamed
36
+ * Anthropic usage events; per-worker in-flight cap; revoke(workerKey) /
37
+ * revokeAll() kill-switch (revoked token → 401 on the NEXT request; in-flight
38
+ * requests are allowed to drain — killing sockets mid-stream corrupts
39
+ * transcripts without adding security, the token is already dead).
40
+ *
41
+ * Honest boundary: the broker protects the REUSABLE CREDENTIAL channel only.
42
+ * A worker can still read other host files (~/.ssh, ~/.aws, ~/.pi/agent/
43
+ * auth.json via bash) and make arbitrary network egress — those need an OS
44
+ * boundary (container/bwrap), which is a separate documented workstream.
45
+ *
46
+ * Version history: v0.11.30 (S\*) made env exact; THIS module removes the
47
+ * need for any real credential to be in the env at all.
48
+ */
49
+
50
+ import * as crypto from "node:crypto";
51
+ import * as fs from "node:fs";
52
+ import * as http from "node:http";
53
+ import * as os from "node:os";
54
+ import * as path from "node:path";
55
+ import { Readable, Transform } from "node:stream";
56
+ import { getCrewEnv, getCrewEnvBool, getCrewEnvInt } from "../../config/env-vars.ts";
57
+ import { logInternalError } from "../../utils/internal-error.ts";
58
+ import { createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
59
+
60
+ // ── Env contract ─────────────────────────────────────────────────────────
61
+ // All child-side vars are control-namespace (PI_CREW_*) except
62
+ // PI_CODING_AGENT_DIR, which is pi's OWN native override (read by the SDK
63
+ // config loader) — it is injected at the spawn-options layer, never through
64
+ // built.env, so assertOnlyControlEnvKeys' contract is untouched.
65
+ export const INFERENCE_BROKER_ENABLE_ENV = "PI_CREW_INFERENCE_BROKER";
66
+ export const INFERENCE_TOKEN_ENV = "PI_CREW_INFERENCE_TOKEN";
67
+ export const INFERENCE_BUDGET_ENV = "PI_CREW_INFERENCE_BUDGET_TOKENS";
68
+ /** pi-native agent-dir override (SDK config.js ENV_AGENT_DIR) — set on the child. */
69
+ export const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
70
+
71
+ const MAX_BODY_BYTES = 64 * 1024 * 1024; // generous: image attachments
72
+ const UPSTREAM_TIMEOUT_MS = 10 * 60 * 1000;
73
+ const MAX_INFLIGHT_PER_WORKER = 4;
74
+
75
+ /** Upstream headers we forward (client auth headers are NEVER forwarded). */
76
+ const FORWARD_REQUEST_HEADERS = new Set([
77
+ "content-type",
78
+ "accept",
79
+ "anthropic-version",
80
+ "anthropic-beta",
81
+ "user-agent",
82
+ "x-stainless-retry-count",
83
+ "x-stainless-timeout",
84
+ "x-request-id",
85
+ ]);
86
+ /** Response headers we must NOT copy (undici already decoded the body). */
87
+ const STRIP_RESPONSE_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
88
+
89
+ interface WorkerEntry {
90
+ workerKey: string;
91
+ token: string;
92
+ revoked: boolean;
93
+ requests: number;
94
+ inputTokens: number;
95
+ outputTokens: number;
96
+ inFlight: number;
97
+ maxTokens: number;
98
+ }
99
+
100
+ export interface InferenceBrokerSnapshot {
101
+ active: boolean;
102
+ baseUrl: string;
103
+ port: number;
104
+ scopedAgentDir: string | undefined;
105
+ /** Provider names with a resolvable upstream credential (v0.11.34). */
106
+ providers: string[];
107
+ }
108
+
109
+ /** One provider's upstream route: base URL + substituted auth header. */
110
+ interface ProviderRoute {
111
+ name: "anthropic" | "openai";
112
+ upstream: string;
113
+ auth: { header: "x-api-key" | "authorization"; value: string };
114
+ }
115
+
116
+ interface BrokerState {
117
+ server: http.Server;
118
+ port: number;
119
+ routes: Map<string, ProviderRoute>; // by path prefix ("/anthropic", "/openai")
120
+ workers: Map<string, WorkerEntry>; // by token
121
+ byKey: Map<string, WorkerEntry>; // by workerKey
122
+ scopedAgentDir: string;
123
+ }
124
+
125
+ // Module-level singleton (same pattern as the env-splice canary window).
126
+ // Started lazily by ensureInferenceBroker() from prepareSpawnContext;
127
+ // stopped by stopInferenceBroker() (extension teardown / tests).
128
+ let broker: BrokerState | undefined;
129
+
130
+ /**
131
+ * Read the PARENT's auth.json credential store — the documented file shape is
132
+ * `{ "<provider>": { "type": "api_key", "key": "..." } }` (verified against
133
+ * the SDK store + a live file). Read ONCE at broker start; the key stays in
134
+ * memory exactly like an env-sourced key. Parse failures are logged WITHOUT
135
+ * the key and ignored (env credentials still work).
136
+ */
137
+ function readAuthJsonCredentials(env: NodeJS.ProcessEnv): Record<string, { type?: string; key?: string }> {
138
+ const agentDir = env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
139
+ const authPath = path.join(agentDir, "auth.json");
140
+ try {
141
+ const raw = fs.readFileSync(authPath, "utf8");
142
+ const parsed: unknown = JSON.parse(raw);
143
+ if (!parsed || typeof parsed !== "object") return {};
144
+ return parsed as Record<string, { type?: string; key?: string }>;
145
+ } catch {
146
+ return {}; // absent/unreadable/malformed — env credentials remain the path
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Resolve every provider route (v0.11.34 multi-provider). Precedence per
152
+ * provider: env credential FIRST (CI behavior identical to v0.11.33), then
153
+ * the parent's auth.json file credential. Returns the routes that resolved
154
+ * and a human-readable trace of what was consulted (never contains keys).
155
+ */
156
+ function resolveProviderRoutes(env: NodeJS.ProcessEnv): { routes: ProviderRoute[]; trace: string } {
157
+ const authJson = readAuthJsonCredentials(env);
158
+ const genericUpstream = getCrewEnv("PI_CREW_INFERENCE_UPSTREAM", env); // tests: applies to every provider
159
+ const routes: ProviderRoute[] = [];
160
+ const consulted: string[] = [];
161
+
162
+ const anthropicUpstream = getCrewEnv("PI_CREW_INFERENCE_UPSTREAM_ANTHROPIC", env) || genericUpstream || "https://api.anthropic.com";
163
+ const anthropicApiKey = env.ANTHROPIC_API_KEY;
164
+ const anthropicAuthToken = env.ANTHROPIC_AUTH_TOKEN;
165
+ const anthropicStored = authJson.anthropic;
166
+ consulted.push("env ANTHROPIC_API_KEY", "env ANTHROPIC_AUTH_TOKEN", "auth.json:anthropic");
167
+ if (anthropicApiKey && anthropicApiKey.length > 0) {
168
+ routes.push({ name: "anthropic", upstream: anthropicUpstream, auth: { header: "x-api-key", value: anthropicApiKey } });
169
+ } else if (anthropicAuthToken && anthropicAuthToken.length > 0) {
170
+ routes.push({
171
+ name: "anthropic",
172
+ upstream: anthropicUpstream,
173
+ auth: { header: "authorization", value: `Bearer ${anthropicAuthToken}` },
174
+ });
175
+ } else if (anthropicStored?.type === "api_key" && typeof anthropicStored.key === "string" && anthropicStored.key.length > 0) {
176
+ routes.push({ name: "anthropic", upstream: anthropicUpstream, auth: { header: "x-api-key", value: anthropicStored.key } });
177
+ }
178
+
179
+ const openaiUpstream = getCrewEnv("PI_CREW_INFERENCE_UPSTREAM_OPENAI", env) || genericUpstream || "https://api.openai.com/v1";
180
+ const openaiApiKey = env.OPENAI_API_KEY;
181
+ const openaiStored = authJson.openai;
182
+ consulted.push("env OPENAI_API_KEY", "auth.json:openai");
183
+ if (openaiApiKey && openaiApiKey.length > 0) {
184
+ routes.push({ name: "openai", upstream: openaiUpstream, auth: { header: "authorization", value: `Bearer ${openaiApiKey}` } });
185
+ } else if (openaiStored?.type === "api_key" && typeof openaiStored.key === "string" && openaiStored.key.length > 0) {
186
+ routes.push({ name: "openai", upstream: openaiUpstream, auth: { header: "authorization", value: `Bearer ${openaiStored.key}` } });
187
+ }
188
+
189
+ return { routes, trace: consulted.join(", ") };
190
+ }
191
+
192
+ /**
193
+ * Idempotent lazy start (ASYNC — http listen needs an event-loop turn; call
194
+ * sites: child-pi.ts run() BEFORE prepareSpawnContext, which stays sync).
195
+ * Returns the live snapshot, or `{active:false}` when the feature flag is
196
+ * off. FAIL-CLOSED: flag on + no upstream credential → THROWS (the run fails
197
+ * loudly; workers must never silently fall back to receiving real provider
198
+ * keys).
199
+ */
200
+ export async function maybeStartInferenceBroker(env: NodeJS.ProcessEnv = process.env): Promise<InferenceBrokerSnapshot> {
201
+ if (broker)
202
+ return {
203
+ active: true,
204
+ baseUrl: `http://127.0.0.1:${broker.port}`,
205
+ port: broker.port,
206
+ scopedAgentDir: broker.scopedAgentDir,
207
+ providers: [...broker.routes.values()].map((r) => r.name),
208
+ };
209
+ if (!getCrewEnvBool(INFERENCE_BROKER_ENABLE_ENV))
210
+ return { active: false, baseUrl: "", port: 0, scopedAgentDir: undefined, providers: [] };
211
+
212
+ const { routes, trace } = resolveProviderRoutes(env);
213
+ if (routes.length === 0) {
214
+ throw new Error(
215
+ `${INFERENCE_BROKER_ENABLE_ENV}=1 but no provider credential resolved for the inference broker (consulted: ${trace}) — fail-closed: workers would otherwise receive no usable credential, or worse, fall back to real provider keys. Provide ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY in the parent env, an api_key entry for anthropic/openai in the parent's auth.json, or unset ${INFERENCE_BROKER_ENABLE_ENV}.`,
216
+ );
217
+ }
218
+ broker = await createBrokerServer(routes);
219
+ return {
220
+ active: true,
221
+ baseUrl: `http://127.0.0.1:${broker.port}`,
222
+ port: broker.port,
223
+ scopedAgentDir: broker.scopedAgentDir,
224
+ providers: [...broker.routes.values()].map((r) => r.name),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * SYNC guard for the spawn path: when the flag is on, the broker MUST already
230
+ * be running (child-pi.ts run() awaits maybeStartInferenceBroker() first).
231
+ * Direct prepareSpawnContext callers under an active flag without a started
232
+ * broker get a loud programming error instead of a keyless worker.
233
+ */
234
+ export function requireInferenceBrokerIfEnabled(): InferenceBrokerSnapshot {
235
+ if (broker)
236
+ return {
237
+ active: true,
238
+ baseUrl: `http://127.0.0.1:${broker.port}`,
239
+ port: broker.port,
240
+ scopedAgentDir: broker.scopedAgentDir,
241
+ providers: [...broker.routes.values()].map((r) => r.name),
242
+ };
243
+ if (!getCrewEnvBool(INFERENCE_BROKER_ENABLE_ENV))
244
+ return { active: false, baseUrl: "", port: 0, scopedAgentDir: undefined, providers: [] };
245
+ throw new Error(
246
+ `${INFERENCE_BROKER_ENABLE_ENV}=1 but the broker is not started — run() must await maybeStartInferenceBroker() before prepareSpawnContext()`,
247
+ );
248
+ }
249
+
250
+ /** Read-only view for spawn-option builders. Never starts anything. */
251
+ export function getInferenceBrokerSnapshot(): InferenceBrokerSnapshot {
252
+ if (!broker) return { active: false, baseUrl: "", port: 0, scopedAgentDir: undefined, providers: [] };
253
+ return {
254
+ active: true,
255
+ baseUrl: `http://127.0.0.1:${broker.port}`,
256
+ port: broker.port,
257
+ scopedAgentDir: broker.scopedAgentDir,
258
+ providers: [...broker.routes.values()].map((r) => r.name),
259
+ };
260
+ }
261
+
262
+ async function createBrokerServer(routes: ProviderRoute[]): Promise<BrokerState> {
263
+ const workers = new Map<string, WorkerEntry>();
264
+ const byKey = new Map<string, WorkerEntry>();
265
+ const routeMap = new Map<string, ProviderRoute>(routes.map((r) => [`/${r.name}`, r]));
266
+
267
+ const server = http.createServer((req, res) => {
268
+ void handleRequest(req, res, { routes: routeMap, workers });
269
+ });
270
+ server.unref(); // never hold the parent's event loop open
271
+ await new Promise<void>((resolve, reject) => {
272
+ server.once("error", reject);
273
+ server.listen(0, "127.0.0.1", () => {
274
+ server.off("error", reject);
275
+ resolve();
276
+ });
277
+ });
278
+ const address = server.address();
279
+ if (!address || typeof address === "string") throw new Error("inference broker failed to bind a loopback port");
280
+ const port = address.port;
281
+
282
+ const scopedAgentDir = buildScopedAgentDir(port, routes);
283
+
284
+ return { server, port, routes: routeMap, workers, byKey, scopedAgentDir };
285
+ }
286
+
287
+ /**
288
+ * Scoped agent dir for workers: models.json ONLY — no auth.json, no
289
+ * extensions, no skills, no settings. The single override reroutes the
290
+ * built-in anthropic provider to the broker with per-worker token
291
+ * interpolation at request time. Mode 0o600 file / 0o700 dir.
292
+ */
293
+ function buildScopedAgentDir(port: number, routes: ProviderRoute[]): string {
294
+ const dir = createSafeTempDir(getPiTempBase(), "pi-crew-inference-");
295
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
296
+ // Every resolvable provider gets a PATH-PREFIXED baseUrl ("/anthropic",
297
+ // "/openai") so the broker routes deterministically by prefix and forwards
298
+ // the remaining path verbatim to that provider's upstream.
299
+ const providers: Record<string, { baseUrl: string; apiKey: string }> = {};
300
+ for (const route of routes) {
301
+ providers[route.name] = {
302
+ baseUrl: `http://127.0.0.1:${port}/${route.name}`,
303
+ apiKey: `$${INFERENCE_TOKEN_ENV}`,
304
+ };
305
+ }
306
+ const models = {
307
+ $schema: "https://pi.dev/schemas/models.schema.json",
308
+ providers,
309
+ };
310
+ const file = path.join(dir, "models.json");
311
+ fs.writeFileSync(file, `${JSON.stringify(models, null, "\t")}\n`, { mode: 0o600 });
312
+ return dir;
313
+ }
314
+
315
+ /** Extract the caller token from x-api-key or Authorization: Bearer. */
316
+ function extractToken(req: http.IncomingMessage): string | undefined {
317
+ const apiKey = req.headers["x-api-key"];
318
+ if (typeof apiKey === "string" && apiKey.length > 0) return apiKey;
319
+ const auth = req.headers.authorization;
320
+ if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice("Bearer ".length);
321
+ return undefined;
322
+ }
323
+
324
+ function deny(res: http.ServerResponse, status: number, message: string): void {
325
+ const body = JSON.stringify({ type: "error", error: { type: "pi_crew_broker_error", message } });
326
+ res.writeHead(status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(body)) });
327
+ res.end(body);
328
+ }
329
+
330
+ async function handleRequest(
331
+ req: http.IncomingMessage,
332
+ res: http.ServerResponse,
333
+ ctx: { routes: Map<string, ProviderRoute>; workers: Map<string, WorkerEntry> },
334
+ ): Promise<void> {
335
+ try {
336
+ // (0) Provider routing: the FIRST path segment names the provider
337
+ // ("/anthropic/v1/messages", "/openai/chat/completions"); the broker
338
+ // forwards the REMAINING path verbatim to that provider's upstream.
339
+ const url = req.url ?? "/";
340
+ let route: ProviderRoute | undefined;
341
+ let upstreamPath = url;
342
+ for (const [prefix, candidate] of ctx.routes) {
343
+ if (url === prefix || url.startsWith(`${prefix}/`)) {
344
+ route = candidate;
345
+ upstreamPath = url.slice(prefix.length) || "/";
346
+ break;
347
+ }
348
+ }
349
+ if (!route) {
350
+ deny(res, 404, "unknown provider route (expected /anthropic/... or /openai/...)");
351
+ return;
352
+ }
353
+ // (1) Auth — fail-closed, generic errors (never echo tokens).
354
+ const token = extractToken(req);
355
+ const worker = token ? ctx.workers.get(token) : undefined;
356
+ if (!worker || worker.revoked) {
357
+ deny(res, 401, "invalid or revoked inference token");
358
+ return;
359
+ }
360
+ // (2) Budget + concurrency (per worker).
361
+ if (worker.maxTokens > 0 && worker.inputTokens + worker.outputTokens >= worker.maxTokens) {
362
+ deny(res, 429, "worker inference token budget exhausted");
363
+ return;
364
+ }
365
+ if (worker.inFlight >= MAX_INFLIGHT_PER_WORKER) {
366
+ deny(res, 429, "too many concurrent inference requests for this worker");
367
+ return;
368
+ }
369
+ // (3) Read the request body (bounded).
370
+ const chunks: Buffer[] = [];
371
+ let size = 0;
372
+ for await (const chunk of req) {
373
+ size += (chunk as Buffer).length;
374
+ if (size > MAX_BODY_BYTES) {
375
+ deny(res, 413, "request body too large");
376
+ return;
377
+ }
378
+ chunks.push(chunk as Buffer);
379
+ }
380
+ const body = Buffer.concat(chunks);
381
+ // (4) Forward — client auth headers DROPPED, real key substituted.
382
+ const headers: Record<string, string> = {};
383
+ for (const [name, value] of Object.entries(req.headers)) {
384
+ if (!FORWARD_REQUEST_HEADERS.has(name.toLowerCase())) continue;
385
+ if (typeof value === "string") headers[name.toLowerCase()] = value;
386
+ }
387
+ headers[route.auth.header] = route.auth.value;
388
+ const upstreamRes = await fetch(`${route.upstream}${upstreamPath}`, {
389
+ // eslint-disable-next-line
390
+ method: req.method ?? "POST",
391
+ headers,
392
+ body: body.length > 0 ? body : undefined,
393
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
394
+ });
395
+ // (5) Relay the response — strip transport framing, stream through,
396
+ // count usage from Anthropic SSE events / JSON bodies on the way.
397
+ const outHeaders: Record<string, string> = {};
398
+ upstreamRes.headers.forEach((value, name) => {
399
+ if (!STRIP_RESPONSE_HEADERS.has(name.toLowerCase())) outHeaders[name.toLowerCase()] = value;
400
+ });
401
+ res.writeHead(upstreamRes.status, outHeaders);
402
+ worker.requests += 1;
403
+ const usage = new UsageCounter();
404
+ const finish = () => {
405
+ worker.inFlight -= 1;
406
+ worker.inputTokens += usage.input;
407
+ worker.outputTokens += usage.output;
408
+ };
409
+ res.on("close", finish);
410
+ if (!upstreamRes.body) {
411
+ res.end();
412
+ return;
413
+ }
414
+ const nodeStream = Readable.fromWeb(upstreamRes.body as import("node:stream/web").ReadableStream);
415
+ nodeStream.on("error", (e) => {
416
+ console.error("[ib-debug] nodeStream error:", e?.message);
417
+ res.destroy();
418
+ });
419
+ nodeStream.pipe(usage.tee).pipe(res);
420
+ } catch (error) {
421
+ logInternalError("inference-broker.forward", undefined, error instanceof Error ? error.message : String(error), "warn");
422
+ if (!res.headersSent) deny(res, 502, "inference broker upstream failure");
423
+ else res.destroy();
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Count Anthropic usage from a streamed response without buffering it:
429
+ * SSE `message_start` carries usage.input_tokens; `message_delta` carries
430
+ * usage.output_tokens (cumulative — take max). Non-stream JSON bodies carry a
431
+ * top-level usage object (also scanned — `data:`-less lines are parsed
432
+ * defensively). `message_start` nests usage under evt.message.usage.
433
+ */
434
+ class UsageCounter {
435
+ input = 0;
436
+ output = 0;
437
+ private leftover = "";
438
+ // NB: named `tee` — a field named `transform` would SHADOW the Transform
439
+ // base class method slot and silently break piping.
440
+ readonly tee = new Transform({
441
+ transform: (chunk: Buffer, _enc: string, cb: (e: Error | null, d?: Buffer) => void) => {
442
+ this.scan(chunk);
443
+ cb(null, chunk);
444
+ },
445
+ });
446
+
447
+ private scan(chunk: Buffer): void {
448
+ const text = this.leftover + chunk.toString("utf8");
449
+ const lines = text.split("\n");
450
+ this.leftover = lines.pop() ?? "";
451
+ for (const line of lines) {
452
+ const trimmed = line.trim();
453
+ const payload = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed.startsWith("{") ? trimmed : "";
454
+ if (!payload || payload === "[DONE]") continue;
455
+ try {
456
+ const evt = JSON.parse(payload);
457
+ // SSE message_start: usage nested under evt.message.usage.
458
+ // SSE message_delta: evt.usage.output_tokens is CUMULATIVE (take max).
459
+ // Non-stream JSON body: top-level evt.usage, both absolute.
460
+ const usage = evt?.message?.usage ?? evt?.usage;
461
+ if (!usage || typeof usage !== "object") continue;
462
+ // Anthropic: input_tokens / output_tokens. OpenAI chat-completions:
463
+ // prompt_tokens / completion_tokens (final usage chunk, absolute).
464
+ const inTok =
465
+ typeof usage.input_tokens === "number"
466
+ ? usage.input_tokens
467
+ : typeof usage.prompt_tokens === "number"
468
+ ? usage.prompt_tokens
469
+ : undefined;
470
+ const outTok =
471
+ typeof usage.output_tokens === "number"
472
+ ? usage.output_tokens
473
+ : typeof usage.completion_tokens === "number"
474
+ ? usage.completion_tokens
475
+ : undefined;
476
+ if (typeof inTok === "number" && evt.type !== "message_delta") this.input = Math.max(this.input, inTok);
477
+ if (typeof outTok === "number") this.output = Math.max(this.output, outTok);
478
+ } catch {
479
+ // non-JSON line — ignore
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Issue a per-worker token. Re-registering the same workerKey REPLACES the
487
+ * old token (old one dies immediately) — retries of one task must never
488
+ * accumulate live tokens.
489
+ */
490
+ export function registerInferenceWorker(workerKey: string): string {
491
+ if (!broker) throw new Error("inference broker not started");
492
+ const existing = broker.byKey.get(workerKey);
493
+ if (existing) {
494
+ existing.revoked = true;
495
+ broker.workers.delete(existing.token);
496
+ broker.byKey.delete(workerKey);
497
+ }
498
+ const token = crypto.randomBytes(32).toString("hex");
499
+ const maxTokens = getCrewEnvInt(INFERENCE_BUDGET_ENV) ?? 0;
500
+ const entry: WorkerEntry = { workerKey, token, revoked: false, requests: 0, inputTokens: 0, outputTokens: 0, inFlight: 0, maxTokens };
501
+ broker.workers.set(token, entry);
502
+ broker.byKey.set(workerKey, entry);
503
+ return token;
504
+ }
505
+
506
+ /** Kill-switch for one worker: its token dies on the NEXT request. */
507
+ export function revokeInferenceWorker(workerKey: string): boolean {
508
+ if (!broker) return false;
509
+ const entry = broker.byKey.get(workerKey);
510
+ if (!entry) return false;
511
+ entry.revoked = true;
512
+ broker.workers.delete(entry.token);
513
+ broker.byKey.delete(workerKey);
514
+ return true;
515
+ }
516
+
517
+ export function revokeAllInferenceWorkers(): number {
518
+ if (!broker) return 0;
519
+ const n = broker.byKey.size;
520
+ for (const entry of broker.byKey.values()) entry.revoked = true;
521
+ broker.workers.clear();
522
+ broker.byKey.clear();
523
+ return n;
524
+ }
525
+
526
+ /** Redacted per-worker stats (never includes tokens). */
527
+ export function inferenceBrokerStats(): Array<{ workerKey: string; requests: number; inputTokens: number; outputTokens: number }> {
528
+ if (!broker) return [];
529
+ return [...broker.byKey.values()].map((e) => ({
530
+ workerKey: e.workerKey,
531
+ requests: e.requests,
532
+ inputTokens: e.inputTokens,
533
+ outputTokens: e.outputTokens,
534
+ }));
535
+ }
536
+
537
+ /** Stop the broker: revoke every token, close the server, remove the scoped dir. */
538
+ export function stopInferenceBroker(): void {
539
+ if (!broker) return;
540
+ revokeAllInferenceWorkers();
541
+ broker.server.close();
542
+ try {
543
+ fs.rmSync(broker.scopedAgentDir, { recursive: true, force: true });
544
+ } catch {
545
+ // best-effort cleanup (tracked temp cleaner will catch it)
546
+ }
547
+ broker = undefined;
548
+ }
549
+
550
+ /** @internal Reset module state — TESTS ONLY (never call in production paths). */
551
+ export function __resetInferenceBrokerForTests(): void {
552
+ stopInferenceBroker();
553
+ }