github-router 0.3.36 → 0.3.38
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/dist/{lifecycle-DxRKANCV.js → lifecycle-CMnTe0W7.js} +2 -2
- package/dist/{lifecycle-3OXRVrtQ.js → lifecycle-Ho67_Rew.js} +20 -2
- package/dist/lifecycle-Ho67_Rew.js.map +1 -0
- package/dist/main.js +204 -124
- package/dist/main.js.map +1 -1
- package/dist/{paths-Cr2gfGiA.js → paths-C-GyxwCW.js} +2 -2
- package/dist/{paths-Cr2gfGiA.js.map → paths-C-GyxwCW.js.map} +1 -1
- package/dist/{paths-Cf3OVCaJ.js → paths-DZwqh1p5.js} +1 -1
- package/package.json +1 -1
- package/dist/lifecycle-3OXRVrtQ.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./paths-
|
|
2
|
-
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-
|
|
1
|
+
import "./paths-C-GyxwCW.js";
|
|
2
|
+
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-Ho67_Rew.js";
|
|
3
3
|
|
|
4
4
|
export { WorktreeRegistry, getInstanceUuid, recordWorkerRepo, registerExitHandlers, sweepRegistry, sweepStaleWorktreesAtBoot };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-
|
|
1
|
+
import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-C-GyxwCW.js";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
@@ -110,6 +110,24 @@ function sweepRegistry() {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
/**
|
|
113
|
+
* Windows ConPTY / node-pty signal behavior:
|
|
114
|
+
*
|
|
115
|
+
* When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes
|
|
116
|
+
* the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the
|
|
117
|
+
* process group. Node.js translates this into SIGINT (NOT SIGTERM). The
|
|
118
|
+
* process has a ~5-second window before forced termination.
|
|
119
|
+
*
|
|
120
|
+
* Implication: the SIGTERM handler below may NEVER fire in node-pty
|
|
121
|
+
* environments. This is by design — the three-layer cleanup architecture
|
|
122
|
+
* ensures coverage:
|
|
123
|
+
* 1. Per-call cleanup (engine.ts finally block) — happy path
|
|
124
|
+
* 2. SIGINT handler (this file) — ConPTY close, Ctrl+C
|
|
125
|
+
* 3. `exit` handler (this file) — unconditional, fires on any exit
|
|
126
|
+
* 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery
|
|
127
|
+
*
|
|
128
|
+
* Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.
|
|
129
|
+
*/
|
|
130
|
+
/**
|
|
113
131
|
* Wire up SIGINT/SIGTERM/exit handlers that walk the registry and
|
|
114
132
|
* remove every entry. Idempotent: subsequent calls swap the registry
|
|
115
133
|
* pointer but do NOT register additional process listeners (otherwise
|
|
@@ -289,4 +307,4 @@ async function sweepStaleWorktreesAtBoot() {
|
|
|
289
307
|
|
|
290
308
|
//#endregion
|
|
291
309
|
export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
|
|
292
|
-
//# sourceMappingURL=lifecycle-
|
|
310
|
+
//# sourceMappingURL=lifecycle-Ho67_Rew.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle-Ho67_Rew.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|
package/dist/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-
|
|
3
|
-
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-
|
|
2
|
+
import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-C-GyxwCW.js";
|
|
3
|
+
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-Ho67_Rew.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { defineCommand, runMain } from "citty";
|
|
6
6
|
import consola from "consola";
|
|
@@ -3055,10 +3055,11 @@ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
|
|
|
3055
3055
|
const id = randomUUID();
|
|
3056
3056
|
const ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}`, { headers: { authorization: `Bearer ${endpoint.token}` } });
|
|
3057
3057
|
let settled = false;
|
|
3058
|
+
let timer = void 0;
|
|
3058
3059
|
const finish = (fn) => {
|
|
3059
3060
|
if (settled) return;
|
|
3060
3061
|
settled = true;
|
|
3061
|
-
clearTimeout(timer);
|
|
3062
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3062
3063
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
3063
3064
|
try {
|
|
3064
3065
|
ws.close();
|
|
@@ -3073,7 +3074,7 @@ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
|
|
|
3073
3074
|
}
|
|
3074
3075
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
3075
3076
|
}
|
|
3076
|
-
|
|
3077
|
+
timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`))), timeoutMs);
|
|
3077
3078
|
ws.on("open", () => {
|
|
3078
3079
|
if (settled) {
|
|
3079
3080
|
try {
|
|
@@ -3190,7 +3191,7 @@ function logAudit$1(record) {
|
|
|
3190
3191
|
try {
|
|
3191
3192
|
const fs$2 = await import("node:fs/promises");
|
|
3192
3193
|
const path$2 = await import("node:path");
|
|
3193
|
-
const { PATHS: PATHS$1 } = await import("./paths-
|
|
3194
|
+
const { PATHS: PATHS$1 } = await import("./paths-DZwqh1p5.js");
|
|
3194
3195
|
const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
|
|
3195
3196
|
await fs$2.mkdir(dir, { recursive: true });
|
|
3196
3197
|
const line = JSON.stringify({
|
|
@@ -5181,6 +5182,117 @@ async function acquireWorkerSlot(signal) {
|
|
|
5181
5182
|
};
|
|
5182
5183
|
}
|
|
5183
5184
|
|
|
5185
|
+
//#endregion
|
|
5186
|
+
//#region src/lib/diagnose-response.ts
|
|
5187
|
+
const PREVIEW_LIMIT = 200;
|
|
5188
|
+
async function parseJsonOrDiagnose(response, routePath) {
|
|
5189
|
+
const cloned = response.clone();
|
|
5190
|
+
try {
|
|
5191
|
+
return await response.json();
|
|
5192
|
+
} catch (error) {
|
|
5193
|
+
const contentType = response.headers.get("content-type") ?? "(none)";
|
|
5194
|
+
const bodyText = await cloned.text().catch(() => "(unreadable)");
|
|
5195
|
+
const preview = bodyText.length > PREVIEW_LIMIT ? bodyText.slice(0, PREVIEW_LIMIT) + "...(truncated)" : bodyText;
|
|
5196
|
+
consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..${PREVIEW_LIMIT}]=${JSON.stringify(preview)}`);
|
|
5197
|
+
throw error;
|
|
5198
|
+
}
|
|
5199
|
+
}
|
|
5200
|
+
|
|
5201
|
+
//#endregion
|
|
5202
|
+
//#region src/lib/response-cap.ts
|
|
5203
|
+
/**
|
|
5204
|
+
* Hard byte cap for non-streaming upstream response bodies.
|
|
5205
|
+
*
|
|
5206
|
+
* Anthropic responses with large tool_use blocks can legitimately reach
|
|
5207
|
+
* several MB, but a multi-GB body is either a buggy upstream or a malicious
|
|
5208
|
+
* one. Buffering it would OOM the proxy and crash all in-flight requests.
|
|
5209
|
+
*
|
|
5210
|
+
* Applies to /v1/messages, /v1/chat/completions, and /v1/responses.
|
|
5211
|
+
*/
|
|
5212
|
+
const MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024;
|
|
5213
|
+
/**
|
|
5214
|
+
* Read a Response body with a hard byte cap, then parse as JSON.
|
|
5215
|
+
*
|
|
5216
|
+
* Falls back to the fast path (response.json()) when Content-Length is
|
|
5217
|
+
* present and within the cap, avoiding the streaming-reader overhead for
|
|
5218
|
+
* the vast majority of normal responses.
|
|
5219
|
+
*
|
|
5220
|
+
* When the cap is hit:
|
|
5221
|
+
* - the reader is cancelled to release the upstream socket
|
|
5222
|
+
* - a structured Anthropic-format error is returned to the caller
|
|
5223
|
+
* (the caller wraps it in c.json(), not throws — the client gets a
|
|
5224
|
+
* clean 413 error, not an unhandled-rejection crash)
|
|
5225
|
+
*
|
|
5226
|
+
* Returns `{ ok: true, value }` on success or `{ ok: false, errorResponse, status }`
|
|
5227
|
+
* on cap exceeded.
|
|
5228
|
+
*/
|
|
5229
|
+
async function readResponseBodyCapped(response, routePath, capBytes = MAX_RESPONSE_BODY_BYTES) {
|
|
5230
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
5231
|
+
const contentLength = contentLengthHeader ? parseInt(contentLengthHeader, 10) : NaN;
|
|
5232
|
+
if (!isNaN(contentLength) && contentLength <= capBytes) return {
|
|
5233
|
+
ok: true,
|
|
5234
|
+
value: await parseJsonOrDiagnose(response, routePath)
|
|
5235
|
+
};
|
|
5236
|
+
const reader = response.body?.getReader();
|
|
5237
|
+
if (!reader) return {
|
|
5238
|
+
ok: true,
|
|
5239
|
+
value: await parseJsonOrDiagnose(response, routePath)
|
|
5240
|
+
};
|
|
5241
|
+
const chunks = [];
|
|
5242
|
+
let totalBytes = 0;
|
|
5243
|
+
let capped = false;
|
|
5244
|
+
try {
|
|
5245
|
+
while (true) {
|
|
5246
|
+
const { done, value } = await reader.read();
|
|
5247
|
+
if (done) break;
|
|
5248
|
+
if (!value) continue;
|
|
5249
|
+
totalBytes += value.byteLength;
|
|
5250
|
+
if (totalBytes > capBytes) {
|
|
5251
|
+
capped = true;
|
|
5252
|
+
try {
|
|
5253
|
+
await reader.cancel("size_cap");
|
|
5254
|
+
} catch {}
|
|
5255
|
+
break;
|
|
5256
|
+
}
|
|
5257
|
+
chunks.push(value);
|
|
5258
|
+
}
|
|
5259
|
+
} catch (err) {
|
|
5260
|
+
if (!capped) consola.warn(`readResponseBodyCapped: read error at ${routePath}:`, err);
|
|
5261
|
+
}
|
|
5262
|
+
if (capped) {
|
|
5263
|
+
consola.warn(`Non-streaming upstream response at ${routePath} exceeded ${capBytes} bytes (10 MiB cap); dropping body to prevent OOM. Check upstream health.`);
|
|
5264
|
+
return {
|
|
5265
|
+
ok: false,
|
|
5266
|
+
status: 502,
|
|
5267
|
+
errorResponse: {
|
|
5268
|
+
type: "error",
|
|
5269
|
+
error: {
|
|
5270
|
+
type: "api_error",
|
|
5271
|
+
message: `Upstream response body exceeded the 10 MiB size cap for non-streaming ${routePath}. The upstream may be misbehaving. Try enabling streaming (stream: true) which handles large responses chunk-by-chunk.`
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
};
|
|
5275
|
+
}
|
|
5276
|
+
const merged = new Uint8Array(totalBytes);
|
|
5277
|
+
let offset = 0;
|
|
5278
|
+
for (const chunk of chunks) {
|
|
5279
|
+
merged.set(chunk, offset);
|
|
5280
|
+
offset += chunk.byteLength;
|
|
5281
|
+
}
|
|
5282
|
+
const text = new TextDecoder().decode(merged);
|
|
5283
|
+
try {
|
|
5284
|
+
return {
|
|
5285
|
+
ok: true,
|
|
5286
|
+
value: JSON.parse(text)
|
|
5287
|
+
};
|
|
5288
|
+
} catch (err) {
|
|
5289
|
+
const preview = text.slice(0, 200);
|
|
5290
|
+
const contentType = response.headers.get("content-type") ?? "(none)";
|
|
5291
|
+
consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..200]=${JSON.stringify(preview)}`);
|
|
5292
|
+
throw err;
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
|
|
5184
5296
|
//#endregion
|
|
5185
5297
|
//#region src/services/copilot/create-chat-completions.ts
|
|
5186
5298
|
const createChatCompletions = async (payload, modelHeaders, callerSignal) => {
|
|
@@ -5222,7 +5334,12 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal) => {
|
|
|
5222
5334
|
}));
|
|
5223
5335
|
}
|
|
5224
5336
|
if (payload.stream) return events(response);
|
|
5225
|
-
|
|
5337
|
+
const cappedResult = await readResponseBodyCapped(response, "/v1/chat/completions", MAX_RESPONSE_BODY_BYTES);
|
|
5338
|
+
if (!cappedResult.ok) throw new HTTPError("Upstream /v1/chat/completions response exceeded 10 MiB size cap", new Response(JSON.stringify(cappedResult.errorResponse), {
|
|
5339
|
+
status: cappedResult.status,
|
|
5340
|
+
headers: { "content-type": "application/json" }
|
|
5341
|
+
}));
|
|
5342
|
+
return cappedResult.value;
|
|
5226
5343
|
};
|
|
5227
5344
|
|
|
5228
5345
|
//#endregion
|
|
@@ -5883,7 +6000,12 @@ const createResponses = async (payload, modelHeaders, callerSignal) => {
|
|
|
5883
6000
|
throw new HTTPError("Failed to create responses", response);
|
|
5884
6001
|
}
|
|
5885
6002
|
if (payload.stream) return events(response);
|
|
5886
|
-
|
|
6003
|
+
const cappedResult = await readResponseBodyCapped(response, "/v1/responses", MAX_RESPONSE_BODY_BYTES);
|
|
6004
|
+
if (!cappedResult.ok) throw new HTTPError("Upstream /v1/responses response exceeded 10 MiB size cap", new Response(JSON.stringify(cappedResult.errorResponse), {
|
|
6005
|
+
status: cappedResult.status,
|
|
6006
|
+
headers: { "content-type": "application/json" }
|
|
6007
|
+
}));
|
|
6008
|
+
return cappedResult.value;
|
|
5887
6009
|
};
|
|
5888
6010
|
function detectVision(input) {
|
|
5889
6011
|
if (typeof input === "string") return false;
|
|
@@ -6847,6 +6969,34 @@ async function readWithInactivityTimeout(reader, timeoutMs) {
|
|
|
6847
6969
|
}
|
|
6848
6970
|
}
|
|
6849
6971
|
/**
|
|
6972
|
+
* Race an `AsyncIterableIterator.next()` call against an inactivity timeout.
|
|
6973
|
+
*
|
|
6974
|
+
* Follows the same pattern as `readWithInactivityTimeout` (including the
|
|
6975
|
+
* noop catcher to avoid Node 24 unhandled-rejection crashes) but works
|
|
6976
|
+
* with typed iterators that yield parsed objects rather than raw bytes.
|
|
6977
|
+
*
|
|
6978
|
+
* On timeout, throws an `InactivityTimeout` error (same classification as
|
|
6979
|
+
* the byte-reader variant — surfaced to the consumer as `timeout_error` via
|
|
6980
|
+
* `buildOpenAIErrorEvent`).
|
|
6981
|
+
*
|
|
6982
|
+
* @param iterator - An AsyncIterableIterator whose `.next()` we want to race.
|
|
6983
|
+
* @param timeoutMs - Milliseconds before the timeout fires.
|
|
6984
|
+
*/
|
|
6985
|
+
async function readIteratorWithTimeout(iterator, timeoutMs) {
|
|
6986
|
+
let timeoutHandle;
|
|
6987
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
6988
|
+
timeoutHandle = setTimeout(() => {
|
|
6989
|
+
reject(Object.assign(/* @__PURE__ */ new Error("upstream_inactive"), { name: "InactivityTimeout" }));
|
|
6990
|
+
}, timeoutMs);
|
|
6991
|
+
});
|
|
6992
|
+
timeoutPromise.catch(() => {});
|
|
6993
|
+
try {
|
|
6994
|
+
return await Promise.race([iterator.next(), timeoutPromise]);
|
|
6995
|
+
} finally {
|
|
6996
|
+
if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
/**
|
|
6850
7000
|
* Build the SSE wire bytes for an Anthropic-format streaming error event.
|
|
6851
7001
|
* Per Anthropic streaming spec, errors are sent as:
|
|
6852
7002
|
* event: error
|
|
@@ -7162,7 +7312,7 @@ function sseEvent(type, data) {
|
|
|
7162
7312
|
function buildAdvisorStream(opts) {
|
|
7163
7313
|
const advisorModel = opts.advisorModel ?? ADVISOR_DEFAULT_MODEL;
|
|
7164
7314
|
const advisorEffort = opts.advisorEffort ?? ADVISOR_DEFAULT_EFFORT;
|
|
7165
|
-
const aborter = new AbortController();
|
|
7315
|
+
const aborter = opts.externalAborter ?? new AbortController();
|
|
7166
7316
|
let conversation = [...opts.initialConversation];
|
|
7167
7317
|
return new ReadableStream({
|
|
7168
7318
|
async start(controller) {
|
|
@@ -8086,6 +8236,33 @@ function resolvePathOrThrow(rawPath, workspace, opts = {}) {
|
|
|
8086
8236
|
* with the truncated text + a flag. The caller appends the
|
|
8087
8237
|
* truncation marker to the formatted output.
|
|
8088
8238
|
*/
|
|
8239
|
+
/**
|
|
8240
|
+
* Platform-aware kill for child processes. On Windows `child.kill()`
|
|
8241
|
+
* does NOT reliably terminate descendant processes — we use
|
|
8242
|
+
* `taskkill /T /F /PID` instead (same pattern as `bash.ts:killProcessTree`
|
|
8243
|
+
* and `code-search.ts:killChild`). EBUSY is swallowed because taskkill
|
|
8244
|
+
* occasionally races with the child's own teardown.
|
|
8245
|
+
*/
|
|
8246
|
+
function killChildTree(child) {
|
|
8247
|
+
if (!child.pid || child.killed) return;
|
|
8248
|
+
if (process$1.platform === "win32") {
|
|
8249
|
+
try {
|
|
8250
|
+
spawnSync("taskkill", [
|
|
8251
|
+
"/T",
|
|
8252
|
+
"/F",
|
|
8253
|
+
"/PID",
|
|
8254
|
+
String(child.pid)
|
|
8255
|
+
], {
|
|
8256
|
+
stdio: "ignore",
|
|
8257
|
+
windowsHide: true
|
|
8258
|
+
});
|
|
8259
|
+
} catch {}
|
|
8260
|
+
return;
|
|
8261
|
+
}
|
|
8262
|
+
try {
|
|
8263
|
+
child.kill("SIGTERM");
|
|
8264
|
+
} catch {}
|
|
8265
|
+
}
|
|
8089
8266
|
async function runRipgrep(args, cwd, signal) {
|
|
8090
8267
|
const RG_STDOUT_CAP = 10 * 1024 * 1024;
|
|
8091
8268
|
const { rgPath } = resolveRipgrep();
|
|
@@ -8112,9 +8289,7 @@ async function runRipgrep(args, cwd, signal) {
|
|
|
8112
8289
|
let truncated = false;
|
|
8113
8290
|
let settled = false;
|
|
8114
8291
|
const onAbort = () => {
|
|
8115
|
-
if (child.pid && !child.killed)
|
|
8116
|
-
child.kill("SIGTERM");
|
|
8117
|
-
} catch {}
|
|
8292
|
+
if (child.pid && !child.killed) killChildTree(child);
|
|
8118
8293
|
};
|
|
8119
8294
|
if (signal.aborted) onAbort();
|
|
8120
8295
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -8127,9 +8302,7 @@ async function runRipgrep(args, cwd, signal) {
|
|
|
8127
8302
|
stdoutBytes += slice.length;
|
|
8128
8303
|
if (chunk.length > room) {
|
|
8129
8304
|
truncated = true;
|
|
8130
|
-
if (child.pid && !child.killed)
|
|
8131
|
-
child.kill("SIGTERM");
|
|
8132
|
-
} catch {}
|
|
8305
|
+
if (child.pid && !child.killed) killChildTree(child);
|
|
8133
8306
|
}
|
|
8134
8307
|
});
|
|
8135
8308
|
child.stderr?.setEncoding("utf8");
|
|
@@ -8984,7 +9157,7 @@ async function createWorktree(workspaceAbs, opts) {
|
|
|
8984
9157
|
"HEAD"
|
|
8985
9158
|
])).stdout;
|
|
8986
9159
|
} catch {}
|
|
8987
|
-
const lineCount = stat$1.split(
|
|
9160
|
+
const lineCount = stat$1.split(/\r?\n/).filter((l) => l.length > 0).length;
|
|
8988
9161
|
return `[diff truncated at 256KB; ${Math.max(0, lineCount - 1)} files changed]\n${stat$1}`;
|
|
8989
9162
|
};
|
|
8990
9163
|
return {
|
|
@@ -10948,7 +11121,7 @@ function initProxyFromEnv() {
|
|
|
10948
11121
|
//#endregion
|
|
10949
11122
|
//#region package.json
|
|
10950
11123
|
var name = "github-router";
|
|
10951
|
-
var version = "0.3.
|
|
11124
|
+
var version = "0.3.38";
|
|
10952
11125
|
|
|
10953
11126
|
//#endregion
|
|
10954
11127
|
//#region src/lib/approval.ts
|
|
@@ -11365,7 +11538,7 @@ async function handleCompletion$1(c) {
|
|
|
11365
11538
|
return c.json(response);
|
|
11366
11539
|
}
|
|
11367
11540
|
const iterator = response[Symbol.asyncIterator]();
|
|
11368
|
-
const firstResult = await iterator
|
|
11541
|
+
const firstResult = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
|
|
11369
11542
|
if (firstResult.done) consola.warn(`Upstream /chat/completions returned an empty stream at ${c.req.path}`);
|
|
11370
11543
|
let pendingFirstChunk = firstResult.done ? void 0 : firstResult.value;
|
|
11371
11544
|
let upstreamFinished = firstResult.done;
|
|
@@ -11405,7 +11578,7 @@ async function handleCompletion$1(c) {
|
|
|
11405
11578
|
return;
|
|
11406
11579
|
}
|
|
11407
11580
|
try {
|
|
11408
|
-
const result = await iterator
|
|
11581
|
+
const result = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
|
|
11409
11582
|
if (consumerCancelled) {
|
|
11410
11583
|
safeClose(controller);
|
|
11411
11584
|
return;
|
|
@@ -11726,22 +11899,6 @@ function sanitizeAnthropicBody(rawBody) {
|
|
|
11726
11899
|
return JSON.stringify(parsed);
|
|
11727
11900
|
}
|
|
11728
11901
|
|
|
11729
|
-
//#endregion
|
|
11730
|
-
//#region src/lib/diagnose-response.ts
|
|
11731
|
-
const PREVIEW_LIMIT = 200;
|
|
11732
|
-
async function parseJsonOrDiagnose(response, routePath) {
|
|
11733
|
-
const cloned = response.clone();
|
|
11734
|
-
try {
|
|
11735
|
-
return await response.json();
|
|
11736
|
-
} catch (error) {
|
|
11737
|
-
const contentType = response.headers.get("content-type") ?? "(none)";
|
|
11738
|
-
const bodyText = await cloned.text().catch(() => "(unreadable)");
|
|
11739
|
-
const preview = bodyText.length > PREVIEW_LIMIT ? bodyText.slice(0, PREVIEW_LIMIT) + "...(truncated)" : bodyText;
|
|
11740
|
-
consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..${PREVIEW_LIMIT}]=${JSON.stringify(preview)}`);
|
|
11741
|
-
throw error;
|
|
11742
|
-
}
|
|
11743
|
-
}
|
|
11744
|
-
|
|
11745
11902
|
//#endregion
|
|
11746
11903
|
//#region src/routes/messages/count-tokens-handler.ts
|
|
11747
11904
|
const isWebSearchTool$1 = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
|
|
@@ -11912,89 +12069,6 @@ function stripAnthropicOnlyFields$1(body) {
|
|
|
11912
12069
|
|
|
11913
12070
|
//#endregion
|
|
11914
12071
|
//#region src/routes/messages/handler.ts
|
|
11915
|
-
const NON_STREAMING_BODY_CAP_BYTES = 10 * 1024 * 1024;
|
|
11916
|
-
/**
|
|
11917
|
-
* Read a Response body with a hard byte cap, then parse as JSON.
|
|
11918
|
-
*
|
|
11919
|
-
* Falls back to the fast path (response.json()) when Content-Length is
|
|
11920
|
-
* present and within the cap, avoiding the streaming-reader overhead for
|
|
11921
|
-
* the vast majority of normal responses.
|
|
11922
|
-
*
|
|
11923
|
-
* When the cap is hit:
|
|
11924
|
-
* - the reader is cancelled to release the upstream socket
|
|
11925
|
-
* - a structured Anthropic-format error is returned to the caller
|
|
11926
|
-
* (the caller wraps it in c.json(), not throws — the client gets a
|
|
11927
|
-
* clean 413 error, not an unhandled-rejection crash)
|
|
11928
|
-
*
|
|
11929
|
-
* Returns `{ ok: true, value }` on success or `{ ok: false, errorResponse }`
|
|
11930
|
-
* on cap exceeded.
|
|
11931
|
-
*/
|
|
11932
|
-
async function readResponseBodyCapped(response, routePath, capBytes) {
|
|
11933
|
-
const contentLengthHeader = response.headers.get("content-length");
|
|
11934
|
-
const contentLength = contentLengthHeader ? parseInt(contentLengthHeader, 10) : NaN;
|
|
11935
|
-
if (!isNaN(contentLength) && contentLength <= capBytes) return {
|
|
11936
|
-
ok: true,
|
|
11937
|
-
value: await parseJsonOrDiagnose(response, routePath)
|
|
11938
|
-
};
|
|
11939
|
-
const reader = response.body?.getReader();
|
|
11940
|
-
if (!reader) return {
|
|
11941
|
-
ok: true,
|
|
11942
|
-
value: await parseJsonOrDiagnose(response, routePath)
|
|
11943
|
-
};
|
|
11944
|
-
const chunks = [];
|
|
11945
|
-
let totalBytes = 0;
|
|
11946
|
-
let capped = false;
|
|
11947
|
-
try {
|
|
11948
|
-
while (true) {
|
|
11949
|
-
const { done, value } = await reader.read();
|
|
11950
|
-
if (done) break;
|
|
11951
|
-
if (!value) continue;
|
|
11952
|
-
totalBytes += value.byteLength;
|
|
11953
|
-
if (totalBytes > capBytes) {
|
|
11954
|
-
capped = true;
|
|
11955
|
-
try {
|
|
11956
|
-
await reader.cancel("size_cap");
|
|
11957
|
-
} catch {}
|
|
11958
|
-
break;
|
|
11959
|
-
}
|
|
11960
|
-
chunks.push(value);
|
|
11961
|
-
}
|
|
11962
|
-
} catch (err) {
|
|
11963
|
-
if (!capped) consola.warn(`readResponseBodyCapped: read error at ${routePath}:`, err);
|
|
11964
|
-
}
|
|
11965
|
-
if (capped) {
|
|
11966
|
-
consola.warn(`Non-streaming upstream response at ${routePath} exceeded ${capBytes} bytes (10 MiB cap); dropping body to prevent OOM. Check upstream health.`);
|
|
11967
|
-
return {
|
|
11968
|
-
ok: false,
|
|
11969
|
-
status: 502,
|
|
11970
|
-
errorResponse: {
|
|
11971
|
-
type: "error",
|
|
11972
|
-
error: {
|
|
11973
|
-
type: "api_error",
|
|
11974
|
-
message: "Upstream response body exceeded the 10 MiB size cap for non-streaming /v1/messages. The upstream may be misbehaving. Try enabling streaming (stream: true) which handles large responses chunk-by-chunk."
|
|
11975
|
-
}
|
|
11976
|
-
}
|
|
11977
|
-
};
|
|
11978
|
-
}
|
|
11979
|
-
const merged = new Uint8Array(totalBytes);
|
|
11980
|
-
let offset = 0;
|
|
11981
|
-
for (const chunk of chunks) {
|
|
11982
|
-
merged.set(chunk, offset);
|
|
11983
|
-
offset += chunk.byteLength;
|
|
11984
|
-
}
|
|
11985
|
-
const text = new TextDecoder().decode(merged);
|
|
11986
|
-
try {
|
|
11987
|
-
return {
|
|
11988
|
-
ok: true,
|
|
11989
|
-
value: JSON.parse(text)
|
|
11990
|
-
};
|
|
11991
|
-
} catch (err) {
|
|
11992
|
-
const preview = text.slice(0, 200);
|
|
11993
|
-
const contentType = response.headers.get("content-type") ?? "(none)";
|
|
11994
|
-
consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..200]=${JSON.stringify(preview)}`);
|
|
11995
|
-
throw err;
|
|
11996
|
-
}
|
|
11997
|
-
}
|
|
11998
12072
|
const isWebSearchTool = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
|
|
11999
12073
|
/**
|
|
12000
12074
|
* Extract whitelisted beta headers from the incoming request to forward
|
|
@@ -12134,12 +12208,13 @@ async function handleCompletion(c) {
|
|
|
12134
12208
|
const modelId = resolvedModel ?? originalModel;
|
|
12135
12209
|
if (modelId) logEndpointMismatch(modelId, "/v1/messages");
|
|
12136
12210
|
const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
|
|
12211
|
+
const advisorAborter = advisorEnabled ? new AbortController() : void 0;
|
|
12137
12212
|
let response;
|
|
12138
12213
|
try {
|
|
12139
12214
|
response = await createMessages(resolvedBody, {
|
|
12140
12215
|
...selectedModel?.requestHeaders,
|
|
12141
12216
|
...effectiveBetas
|
|
12142
|
-
});
|
|
12217
|
+
}, advisorAborter?.signal);
|
|
12143
12218
|
} catch (error) {
|
|
12144
12219
|
if (error instanceof HTTPError) {
|
|
12145
12220
|
const errorBody = await error.response.clone().text().catch(() => "");
|
|
@@ -12197,7 +12272,8 @@ async function handleCompletion(c) {
|
|
|
12197
12272
|
requestHeaders: {
|
|
12198
12273
|
...selectedModel?.requestHeaders,
|
|
12199
12274
|
...effectiveBetas
|
|
12200
|
-
}
|
|
12275
|
+
},
|
|
12276
|
+
externalAborter: advisorAborter
|
|
12201
12277
|
}), {
|
|
12202
12278
|
status: response.status,
|
|
12203
12279
|
headers: streamHeaders
|
|
@@ -12208,7 +12284,7 @@ async function handleCompletion(c) {
|
|
|
12208
12284
|
headers: streamHeaders
|
|
12209
12285
|
});
|
|
12210
12286
|
}
|
|
12211
|
-
const cappedResult = await readResponseBodyCapped(response, c.req.path,
|
|
12287
|
+
const cappedResult = await readResponseBodyCapped(response, c.req.path, MAX_RESPONSE_BODY_BYTES);
|
|
12212
12288
|
if (!cappedResult.ok) return c.json(cappedResult.errorResponse, cappedResult.status);
|
|
12213
12289
|
const responseBody = cappedResult.value;
|
|
12214
12290
|
logRequest({
|
|
@@ -12561,7 +12637,7 @@ async function handleResponses(c) {
|
|
|
12561
12637
|
let firstChunk;
|
|
12562
12638
|
let upstreamFinished = false;
|
|
12563
12639
|
while (true) {
|
|
12564
|
-
const r = await iterator
|
|
12640
|
+
const r = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
|
|
12565
12641
|
if (r.done) {
|
|
12566
12642
|
upstreamFinished = true;
|
|
12567
12643
|
break;
|
|
@@ -12613,7 +12689,7 @@ async function handleResponses(c) {
|
|
|
12613
12689
|
return;
|
|
12614
12690
|
}
|
|
12615
12691
|
try {
|
|
12616
|
-
const result = await iterator
|
|
12692
|
+
const result = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
|
|
12617
12693
|
if (consumerCancelled) {
|
|
12618
12694
|
safeClose(controller);
|
|
12619
12695
|
return;
|
|
@@ -12722,11 +12798,14 @@ async function handleResponsesCompact(c) {
|
|
|
12722
12798
|
if (!state.copilotToken) throw new Error("Copilot token not found");
|
|
12723
12799
|
if (state.manualApprove) await awaitApproval();
|
|
12724
12800
|
const body = await c.req.json();
|
|
12725
|
-
const
|
|
12801
|
+
const compactUrl = `${copilotBaseUrl(state)}/responses/compact`;
|
|
12802
|
+
const doFetch = () => fetch(compactUrl, {
|
|
12726
12803
|
method: "POST",
|
|
12727
12804
|
headers: copilotHeaders(state),
|
|
12728
|
-
body: JSON.stringify(body)
|
|
12805
|
+
body: JSON.stringify(body),
|
|
12806
|
+
signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS || 3e5)
|
|
12729
12807
|
});
|
|
12808
|
+
const response = await tryRefreshAndRetry(doFetch, "/responses/compact");
|
|
12730
12809
|
if (response.ok) {
|
|
12731
12810
|
logRequest({
|
|
12732
12811
|
method: "POST",
|
|
@@ -12737,6 +12816,7 @@ async function handleResponsesCompact(c) {
|
|
|
12737
12816
|
}
|
|
12738
12817
|
if (response.status === 404) {
|
|
12739
12818
|
consola.debug("Copilot API does not support /responses/compact, using synthetic compaction");
|
|
12819
|
+
await response.body?.cancel().catch(() => {});
|
|
12740
12820
|
return await syntheticCompact(c, body, startTime);
|
|
12741
12821
|
}
|
|
12742
12822
|
logRequest({
|