github-router 0.3.249 → 0.3.255

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.
Files changed (95) hide show
  1. package/dist/attribution-settings-CegtE0TY.js +2347 -0
  2. package/dist/attribution-settings-CegtE0TY.js.map +1 -0
  3. package/dist/auth-CP4q2CjO.js +44 -0
  4. package/dist/auth-CP4q2CjO.js.map +1 -0
  5. package/dist/browser-ext/background.js +8 -1
  6. package/dist/browser-ext/manifest.json +1 -1
  7. package/dist/check-usage-6PB5IUFb.js +43 -0
  8. package/dist/check-usage-6PB5IUFb.js.map +1 -0
  9. package/dist/claude-BiZxoNWq.js +593 -0
  10. package/dist/claude-BiZxoNWq.js.map +1 -0
  11. package/dist/client-CQMfroGV.js +413 -0
  12. package/dist/client-CQMfroGV.js.map +1 -0
  13. package/dist/codex-ST21X3WF.js +85 -0
  14. package/dist/codex-ST21X3WF.js.map +1 -0
  15. package/dist/debug-CjmE9sgG.js +81 -0
  16. package/dist/debug-CjmE9sgG.js.map +1 -0
  17. package/dist/engine-DNCX0Hfd.js +2 -0
  18. package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
  19. package/dist/exec-DTO2oin0.js.map +1 -0
  20. package/dist/file-log-reporter-IFQcL5IK.js +158 -0
  21. package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
  22. package/dist/gate-discovery-CepmbZnc.js +635 -0
  23. package/dist/gate-discovery-CepmbZnc.js.map +1 -0
  24. package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
  25. package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
  26. package/dist/hook-mcp-client-DBvm9608.js +136 -0
  27. package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
  28. package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
  29. package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
  30. package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
  31. package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
  32. package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
  33. package/dist/internal-plan-review-ipnT3bhj.js +114 -0
  34. package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
  35. package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
  36. package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
  37. package/dist/internal-session-bind-DwK2iGW0.js +133 -0
  38. package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
  39. package/dist/internal-stop-hook-CNPGJm63.js +212 -0
  40. package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
  41. package/dist/internal-stop-review-AUcFssrW.js +122 -0
  42. package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
  43. package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
  44. package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
  45. package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
  46. package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
  47. package/dist/lifecycle-Byw5s1G8.js +134 -0
  48. package/dist/lifecycle-Byw5s1G8.js.map +1 -0
  49. package/dist/lifecycle-D80CaWie.js +2 -0
  50. package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
  51. package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
  52. package/dist/lifecycle-DzqCqDlM.js +2 -0
  53. package/dist/main.js +43 -12884
  54. package/dist/main.js.map +1 -1
  55. package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
  56. package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
  57. package/dist/models-B6UnGyRo.js +158 -0
  58. package/dist/models-B6UnGyRo.js.map +1 -0
  59. package/dist/orchestration-hRU4TdWr.js +39 -0
  60. package/dist/orchestration-hRU4TdWr.js.map +1 -0
  61. package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
  62. package/dist/paths-C1cLZ7Ga.js.map +1 -0
  63. package/dist/paths-CwKwc2pi.js +2 -0
  64. package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
  65. package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
  66. package/dist/plan-review-hook-C8x0uKPf.js +159 -0
  67. package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
  68. package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
  69. package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
  70. package/dist/provision-DSkPa5hY.js +656 -0
  71. package/dist/provision-DSkPa5hY.js.map +1 -0
  72. package/dist/serve-sa_7c-yD.js +1511 -0
  73. package/dist/serve-sa_7c-yD.js.map +1 -0
  74. package/dist/server-setup-DcO9qvN3.js +4776 -0
  75. package/dist/server-setup-DcO9qvN3.js.map +1 -0
  76. package/dist/start-siGxuZhO.js +129 -0
  77. package/dist/start-siGxuZhO.js.map +1 -0
  78. package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
  79. package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
  80. package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
  81. package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
  82. package/dist/token-R2ocFC3M.js +1054 -0
  83. package/dist/token-R2ocFC3M.js.map +1 -0
  84. package/dist/version-_Q1WpsQp.js +32 -0
  85. package/dist/version-_Q1WpsQp.js.map +1 -0
  86. package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
  87. package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
  88. package/package.json +1 -1
  89. package/dist/engine-BuuHUb4t.js +0 -2
  90. package/dist/lifecycle-0rXrwjWk.js +0 -2
  91. package/dist/lifecycle-DHZsVN8j.js.map +0 -1
  92. package/dist/lifecycle-hzPwXc3I.js +0 -2
  93. package/dist/paths-BjTMI_xK.js.map +0 -1
  94. package/dist/paths-D3XgmTcQ.js +0 -2
  95. package/dist/peer-mcp-personas-DJzLpfDJ.js.map +0 -1
@@ -0,0 +1,4776 @@
1
+ import { A as ADVISOR_INTERNAL_TOOL_NAME, At as UPSTREAM_FETCH_TIMEOUT_MS, B as relayAnthropicStream, F as buildAnthropicErrorEvent, H as handleMcpPost, I as buildOpenAIErrorEvent, L as isControllerClosedError, M as buildAdvisorStream, Mt as generateRandomPort, N as injectAdvisorTool, P as isAdvisorRequested, Pt as withInstallLock, R as logStreamError, Tt as toolbeltPathOverride, U as agentToolsEnabled, V as handleMcpDelete, at as createMessages, ct as resolveMcpToolTimeoutMs, dt as createChatCompletions, ft as MAX_RESPONSE_BODY_BYTES, it as countTokens, j as ADVISOR_TOOL_INSTRUCTIONS, jt as UPSTREAM_INACTIVITY_TIMEOUT_MS, k as searchWeb, lt as pickEndpoint, mt as parseJsonOrDiagnose, ot as getTokenCount, pt as readResponseBodyCapped, r as assertMcpToolSurfaceConsistent, rt as shimDefaultsToXhigh, st as assembleResponsesPayload, ut as createResponses, w as toolbeltEnabled, z as readIteratorWithTimeout } from "./peer-mcp-personas-BDet588n.js";
2
+ import { t as getPackageVersion } from "./version-_Q1WpsQp.js";
3
+ import { i as ensurePaths, t as PATHS } from "./paths-C1cLZ7Ga.js";
4
+ import { C as copilotHeaders, O as state, S as copilotBaseUrl, a as cacheCopilotVersion, c as filterBetaHeader, d as resolveModel, f as sleep, g as fetchWithTransientRetry, i as tryRefreshAndRetry, l as isNullish, n as setupGitHubAgentToken, o as cacheModels, r as setupGitHubToken, s as cacheVSCodeVersion, t as setupCopilotToken, v as HTTPError, y as forwardError } from "./token-R2ocFC3M.js";
5
+ import { t as getCopilotUsage } from "./get-copilot-usage-DD1N0Xlh.js";
6
+ import { a as resolveExecutable, n as killManagedTree, o as runCommandCapture, r as parseBoolEnv, s as runCommandVoid } from "./exec-DTO2oin0.js";
7
+ import consola from "consola";
8
+ import * as fs$2 from "node:fs";
9
+ import { existsSync } from "node:fs";
10
+ import * as nodePath from "node:path";
11
+ import path from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import fs$1 from "node:fs/promises";
14
+ import os from "node:os";
15
+ import process$1 from "node:process";
16
+ import { spawn } from "node:child_process";
17
+ import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
18
+ import { serve } from "srvx";
19
+ import { createBodyTooLargeError, limitRequestBody } from "srvx/body-limit";
20
+ import { getProxyForUrl } from "proxy-from-env";
21
+ import { Hono } from "hono";
22
+ import { cors } from "hono/cors";
23
+ //#region src/lib/claude-version-check.ts
24
+ const NPM_PACKAGE$1 = "@anthropic-ai/claude-code";
25
+ const THROTTLE_HOURS$1 = 1;
26
+ const NPM_VIEW_TIMEOUT_MS$1 = 5e3;
27
+ const CLAUDE_VERSION_TIMEOUT_MS = 3e3;
28
+ const NPM_INSTALL_TIMEOUT_MS = 12e4;
29
+ /** Path to the throttle cache. Created on demand. */
30
+ function cacheFilePath$1() {
31
+ return path.join(os.homedir(), ".local", "share", "github-router", "last-update-check");
32
+ }
33
+ /**
34
+ * Read the throttle cache. Returns null on missing/corrupt file —
35
+ * triggers a fresh check.
36
+ */
37
+ async function readCache$1() {
38
+ try {
39
+ const raw = await fs$1.readFile(cacheFilePath$1(), "utf8");
40
+ const parsed = JSON.parse(raw);
41
+ if (typeof parsed.checkedAt !== "string" || parsed.installedVersion !== null && typeof parsed.installedVersion !== "string" || parsed.latestVersion !== null && typeof parsed.latestVersion !== "string") return null;
42
+ return parsed;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ async function writeCache$1(cache) {
48
+ try {
49
+ await fs$1.mkdir(path.dirname(cacheFilePath$1()), { recursive: true });
50
+ await fs$1.writeFile(cacheFilePath$1(), JSON.stringify(cache), { mode: 384 });
51
+ } catch (err) {
52
+ consola.debug("Failed to write claude version-check cache:", err);
53
+ }
54
+ }
55
+ /** Check if it's been more than THROTTLE_HOURS since the last check. */
56
+ function shouldCheckNow$1(cache) {
57
+ if (!cache) return true;
58
+ const lastCheck = new Date(cache.checkedAt).getTime();
59
+ if (Number.isNaN(lastCheck)) return true;
60
+ return (Date.now() - lastCheck) / 1e3 / 3600 >= THROTTLE_HOURS$1;
61
+ }
62
+ /**
63
+ * Read the installed `claude` version. Returns null if claude is not
64
+ * on PATH or the version probe fails (e.g. older versions that don't
65
+ * support `--version` cleanly).
66
+ *
67
+ * Windows-safe: `claude` is a `.cmd` shim that `execFile` cannot launch
68
+ * directly. We resolve it to an absolute path (excluding the cwd, so a
69
+ * planted `claude.cmd` in an untrusted repo can't run) and invoke it
70
+ * through the shared exec helper.
71
+ */
72
+ async function getInstalledVersion() {
73
+ const claudePath = resolveExecutable("claude");
74
+ if (!claudePath) return null;
75
+ try {
76
+ const { stdout, code } = await runCommandCapture([claudePath, "--version"], { timeoutMs: CLAUDE_VERSION_TIMEOUT_MS });
77
+ if (code !== 0) return null;
78
+ const match = stdout.match(/(\d+\.\d+\.\d+)/);
79
+ return match ? match[1] : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+ /**
85
+ * Fetch the latest version of @anthropic-ai/claude-code from the npm
86
+ * registry. Returns null on network failure / npm unavailable.
87
+ *
88
+ * Windows-safe: `npm` is `npm.cmd`; resolved to an absolute path
89
+ * (excluding cwd) before invocation.
90
+ */
91
+ async function getLatestVersion$1() {
92
+ const npmPath = resolveExecutable("npm");
93
+ if (!npmPath) return null;
94
+ try {
95
+ const { stdout, code } = await runCommandCapture([
96
+ npmPath,
97
+ "view",
98
+ NPM_PACKAGE$1,
99
+ "version",
100
+ "--silent"
101
+ ], { timeoutMs: NPM_VIEW_TIMEOUT_MS$1 });
102
+ if (code !== 0) return null;
103
+ const v = stdout.trim();
104
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+ /**
110
+ * Compare two semver-shaped strings (only the leading X.Y.Z, no
111
+ * pre-release / metadata handling — sufficient for npm-published
112
+ * stable releases). Returns true if `latest` is strictly higher than
113
+ * `installed`.
114
+ */
115
+ function isNewer(installed, latest) {
116
+ if (!installed || !latest) return false;
117
+ const a = installed.split(".").map((n) => parseInt(n, 10));
118
+ const b = latest.split(".").map((n) => parseInt(n, 10));
119
+ for (let i = 0; i < 3; i++) {
120
+ const av = a[i] ?? 0;
121
+ const bv = b[i] ?? 0;
122
+ if (av < bv) return true;
123
+ if (av > bv) return false;
124
+ }
125
+ return false;
126
+ }
127
+ /**
128
+ * Run a version check (subject to throttle). Side-effect: updates the
129
+ * throttle cache. Returns the comparison result.
130
+ */
131
+ async function checkClaudeVersion(opts = {}) {
132
+ if (opts.noCheck) return {
133
+ installed: false,
134
+ installedVersion: null,
135
+ latestVersion: null,
136
+ needsUpdate: false,
137
+ skipped: true,
138
+ skipReason: "disabled"
139
+ };
140
+ const cache = await readCache$1();
141
+ if (!opts.force && !shouldCheckNow$1(cache)) return {
142
+ installed: cache?.installedVersion !== null,
143
+ installedVersion: cache?.installedVersion ?? null,
144
+ latestVersion: cache?.latestVersion ?? null,
145
+ needsUpdate: isNewer(cache?.installedVersion ?? null, cache?.latestVersion ?? null),
146
+ skipped: true,
147
+ skipReason: "throttled"
148
+ };
149
+ const installedVersion = await getInstalledVersion();
150
+ if (installedVersion === null) return {
151
+ installed: false,
152
+ installedVersion: null,
153
+ latestVersion: null,
154
+ needsUpdate: false,
155
+ skipped: true,
156
+ skipReason: "no-claude"
157
+ };
158
+ const latestVersion = await getLatestVersion$1();
159
+ await writeCache$1({
160
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
161
+ installedVersion,
162
+ latestVersion
163
+ });
164
+ if (latestVersion === null) return {
165
+ installed: true,
166
+ installedVersion,
167
+ latestVersion: null,
168
+ needsUpdate: false,
169
+ skipped: true,
170
+ skipReason: "no-npm"
171
+ };
172
+ return {
173
+ installed: true,
174
+ installedVersion,
175
+ latestVersion,
176
+ needsUpdate: isNewer(installedVersion, latestVersion),
177
+ skipped: false
178
+ };
179
+ }
180
+ /**
181
+ * Heuristic: did `claude update` fail because the subcommand does not
182
+ * exist (a build predating `claude update`), as opposed to a transient
183
+ * update error? We only npm-fall-back on this specific signal — falling
184
+ * back on any failure would install a conflicting npm copy on
185
+ * native-installer machines.
186
+ *
187
+ * Matches the usage/Commander-style "unknown command" messages CLIs emit
188
+ * for an unrecognized subcommand. Deliberately narrow.
189
+ */
190
+ function looksLikeUnknownUpdateCommand(output) {
191
+ return /unknown command|unknown subcommand|unrecognized (sub)?command|invalid command|command not found|is not a (known|valid) command/i.test(output);
192
+ }
193
+ async function updateClaude(latestVersion, deps = {}) {
194
+ const _resolve = deps.resolveExecutable ?? resolveExecutable;
195
+ const _capture = deps.runCommandCapture ?? runCommandCapture;
196
+ const _void = deps.runCommandVoid ?? runCommandVoid;
197
+ const _lock = deps.withInstallLock ?? withInstallLock;
198
+ const claudePath = _resolve("claude");
199
+ if (!claudePath) throw new Error("claude not found on PATH");
200
+ if (!await _lock("claude-update.lock", async () => {
201
+ consola.info(`Updating Claude Code to ${latestVersion} via \`claude update\`...`);
202
+ const { code, stdout, stderr } = await _capture([claudePath, "update"], { timeoutMs: NPM_INSTALL_TIMEOUT_MS });
203
+ const combined = `${stdout}${stderr}`;
204
+ const trimmed = combined.trim();
205
+ if (trimmed) process.stdout.write(trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`);
206
+ if (code === 0) {
207
+ consola.success(`Claude Code updated to ${latestVersion}`);
208
+ return;
209
+ }
210
+ if (!looksLikeUnknownUpdateCommand(combined)) throw new Error(`\`claude update\` exited with code ${code}`);
211
+ const npmPath = _resolve("npm");
212
+ if (!npmPath) throw new Error(`this Claude Code build predates \`claude update\` and npm is not on PATH; update manually: npm install -g ${NPM_PACKAGE$1}@latest`);
213
+ consola.warn(`This Claude Code build predates \`claude update\`; falling back to \`npm install -g ${NPM_PACKAGE$1}@latest\`.`);
214
+ const { code: npmCode, stderr: npmStderr } = await _void([
215
+ npmPath,
216
+ "install",
217
+ "-g",
218
+ `${NPM_PACKAGE$1}@latest`,
219
+ "--silent"
220
+ ], { timeoutMs: NPM_INSTALL_TIMEOUT_MS });
221
+ if (npmCode !== 0) throw new Error(`npm install failed: ${npmStderr.trim() || `exit ${npmCode}`}`);
222
+ consola.success(`${NPM_PACKAGE$1} updated to ${latestVersion}`);
223
+ })) consola.debug("Claude Code update already in progress in another process; skipping.");
224
+ }
225
+ //#endregion
226
+ //#region src/lib/self-update.ts
227
+ /**
228
+ * Self-update for the `github-router` proxy.
229
+ *
230
+ * The proxy is a *running* process, so updating its own global install
231
+ * in place (`npm install -g github-router@latest`) deletes/overwrites
232
+ * files the OS holds open — on Windows that fails deterministically
233
+ * (EPERM/EBUSY) and can corrupt the install. So we never install in
234
+ * place: when a newer version exists we spawn a **detached updater that
235
+ * waits for this process to exit**, then installs. The update takes
236
+ * effect on the next launch — no re-exec, no restart prompt.
237
+ *
238
+ * Best-effort throughout: every failure is swallowed to debug/warn and
239
+ * never blocks startup. The startup probe is bounded and the install is
240
+ * off the critical path (detached), so even a blackholed network can't
241
+ * delay the proxy from serving.
242
+ */
243
+ const NPM_PACKAGE = "github-router";
244
+ const THROTTLE_HOURS = 1;
245
+ const NPM_VIEW_TIMEOUT_MS = 5e3;
246
+ function cacheFilePath() {
247
+ return path.join(os.homedir(), ".local", "share", "github-router", "last-self-update-check");
248
+ }
249
+ async function readCache() {
250
+ try {
251
+ const parsed = JSON.parse(await fs$1.readFile(cacheFilePath(), "utf8"));
252
+ if (typeof parsed.checkedAt !== "string") return null;
253
+ return parsed;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+ async function writeCache(cache) {
259
+ try {
260
+ await fs$1.mkdir(path.dirname(cacheFilePath()), { recursive: true });
261
+ await fs$1.writeFile(cacheFilePath(), JSON.stringify(cache), { mode: 384 });
262
+ } catch (err) {
263
+ consola.debug("Failed to write self-update cache:", err);
264
+ }
265
+ }
266
+ function shouldCheckNow(cache) {
267
+ if (!cache) return true;
268
+ const last = new Date(cache.checkedAt).getTime();
269
+ if (Number.isNaN(last)) return true;
270
+ return (Date.now() - last) / 1e3 / 3600 >= THROTTLE_HOURS;
271
+ }
272
+ async function getLatestVersion(npmPath) {
273
+ try {
274
+ const { stdout, code } = await runCommandCapture([
275
+ npmPath,
276
+ "view",
277
+ NPM_PACKAGE,
278
+ "version",
279
+ "--silent"
280
+ ], { timeoutMs: NPM_VIEW_TIMEOUT_MS });
281
+ if (code !== 0) return null;
282
+ const v = stdout.trim();
283
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+ /**
289
+ * Spawn a detached process that waits for THIS proxy (pid) to exit,
290
+ * then runs `npm install -g github-router@latest`. Fully detached and
291
+ * unref'd so it outlives the proxy; output discarded.
292
+ *
293
+ * The waiter is a tiny inline Node script (Node is guaranteed present —
294
+ * the proxy runs on it) that polls `process.kill(pid, 0)` until the
295
+ * parent is gone, then execs npm. This avoids the Windows file-lock by
296
+ * never touching the global install while the proxy holds it open.
297
+ */
298
+ function spawnDetachedUpdater(npmPath) {
299
+ const waiter = `
300
+ const pid = ${process.pid};
301
+ const { spawn } = require("node:child_process");
302
+ function alive() { try { process.kill(pid, 0); return true } catch { return false } }
303
+ const timer = setInterval(() => {
304
+ if (alive()) return;
305
+ clearInterval(timer);
306
+ const args = ["install", "-g", ${JSON.stringify(`${NPM_PACKAGE}@latest`)}, "--silent"];
307
+ const isWin = process.platform === "win32";
308
+ const child = spawn(${JSON.stringify(npmPath)}, args, {
309
+ stdio: "ignore", windowsHide: true, shell: isWin, detached: !isWin,
310
+ });
311
+ child.on("error", () => process.exit(0));
312
+ child.on("exit", () => process.exit(0));
313
+ // Safety: never hang forever.
314
+ setTimeout(() => process.exit(0), 180000).unref();
315
+ }, 500);
316
+ // Safety cap on the wait itself (e.g. extremely long sessions still
317
+ // eventually give up rather than leak the waiter).
318
+ setTimeout(() => { clearInterval(timer); process.exit(0); }, 24 * 3600 * 1000).unref();
319
+ `.trim();
320
+ spawn(process.execPath, ["-e", waiter], {
321
+ detached: true,
322
+ stdio: "ignore",
323
+ windowsHide: true
324
+ }).unref();
325
+ }
326
+ /**
327
+ * Probe npm for a newer `github-router` and, if found, queue a detached
328
+ * post-exit update. Returns quickly; never throws. Call AFTER the
329
+ * server is listening so the bounded probe can't delay binding.
330
+ */
331
+ async function runSelfUpdate(opts) {
332
+ if (!opts.selfUpdate) return;
333
+ if (parseBoolEnv(process.env.GH_ROUTER_NO_SELF_UPDATE) === true) return;
334
+ try {
335
+ const cache = await readCache();
336
+ if (!opts.force && !shouldCheckNow(cache)) return;
337
+ const installed = getPackageVersion();
338
+ if (installed === "unknown") return;
339
+ const npmPath = resolveExecutable("npm");
340
+ if (!npmPath) return;
341
+ const latest = await getLatestVersion(npmPath);
342
+ await writeCache({
343
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
344
+ installedVersion: installed,
345
+ latestVersion: latest
346
+ });
347
+ if (!latest || !isNewer(installed, latest)) return;
348
+ if (await withInstallLock("self-update.lock", async () => {
349
+ spawnDetachedUpdater(npmPath);
350
+ })) consola.info(`github-router ${installed} → ${latest} update queued; it takes effect on the next launch.`);
351
+ } catch (err) {
352
+ consola.debug("Self-update check failed:", err);
353
+ }
354
+ }
355
+ //#endregion
356
+ //#region src/lib/model-validation.ts
357
+ const ENDPOINT_ALIASES = {
358
+ "/chat/completions": "/chat/completions",
359
+ "/v1/chat/completions": "/chat/completions",
360
+ "/responses": "/responses",
361
+ "/v1/responses": "/responses",
362
+ "/v1/messages": "/v1/messages"
363
+ };
364
+ /**
365
+ * Check whether a model supports the given endpoint, based on cached
366
+ * `supported_endpoints` metadata from the Copilot `/models` response.
367
+ *
368
+ * Returns `true` (allow) when:
369
+ * - the model is not found in the cache (don't block unknown models)
370
+ * - the model has no `supported_endpoints` field (backward-compat)
371
+ * - the endpoint is listed in `supported_endpoints`
372
+ */
373
+ function modelSupportsEndpoint(modelId, path) {
374
+ const endpoint = ENDPOINT_ALIASES[path] ?? path;
375
+ const model = state.models?.data.find((m) => m.id === modelId);
376
+ if (!model) return true;
377
+ const supported = model.supported_endpoints;
378
+ if (!supported || supported.length === 0) return true;
379
+ return supported.includes(endpoint);
380
+ }
381
+ /**
382
+ * Log an error when a model is used on an endpoint it doesn't support.
383
+ * Returns `true` if a mismatch was detected (for testing).
384
+ */
385
+ function logEndpointMismatch(modelId, path) {
386
+ if (modelSupportsEndpoint(modelId, path)) return false;
387
+ const supported = (state.models?.data.find((m) => m.id === modelId))?.supported_endpoints ?? [];
388
+ consola.error(`Model "${modelId}" does not support ${path}. Supported endpoints: ${supported.join(", ")}`);
389
+ return true;
390
+ }
391
+ /**
392
+ * Return model IDs that support the given endpoint.
393
+ */
394
+ function listModelsForEndpoint(path) {
395
+ const endpoint = ENDPOINT_ALIASES[path] ?? path;
396
+ return (state.models?.data ?? []).filter((m) => {
397
+ const supported = m.supported_endpoints;
398
+ if (!supported || supported.length === 0) return true;
399
+ return supported.includes(endpoint);
400
+ }).map((m) => m.id);
401
+ }
402
+ //#endregion
403
+ //#region src/lib/keep-awake/flags.ts
404
+ /**
405
+ * Env-flag parsing for the Windows keep-awake feature. Pure +
406
+ * unit-testable (no spawn, no platform branch — the platform gate lives
407
+ * in `keepAwakeEnabled()`).
408
+ */
409
+ /**
410
+ * True unless the operator opted out via `GH_ROUTER_DISABLE_KEEP_AWAKE`.
411
+ * Keep-awake is ON BY DEFAULT (the win32-only platform gate is applied
412
+ * separately in `keepAwakeEnabled()`). Mirrors the colbert opt-out idiom
413
+ * (`parseBoolEnv(...) !== true`) so on/off semantics don't drift.
414
+ */
415
+ function keepAwakeOptedIn() {
416
+ return parseBoolEnv(process$1.env.GH_ROUTER_DISABLE_KEEP_AWAKE) !== true;
417
+ }
418
+ /**
419
+ * True iff the operator opted IN to keeping the DISPLAY awake too via
420
+ * `GH_ROUTER_KEEP_DISPLAY_ON=1`. Default OFF: the machine stays awake
421
+ * (`ES_SYSTEM_REQUIRED`) but the panel is allowed to sleep.
422
+ */
423
+ function keepDisplayOn() {
424
+ return parseBoolEnv(process$1.env.GH_ROUTER_KEEP_DISPLAY_ON) === true;
425
+ }
426
+ //#endregion
427
+ //#region src/lib/keep-awake/helper.ts
428
+ /**
429
+ * The persistent PowerShell helper that holds a Win32
430
+ * `SetThreadExecutionState` assertion for the proxy's lifetime.
431
+ *
432
+ * `SetThreadExecutionState` is THREAD-scoped: a one-shot process that
433
+ * sets it and exits releases the assertion immediately. So a long-lived
434
+ * helper holds it on its own main thread and blocks on stdin; when the
435
+ * proxy dies (clean exit, signal, SIGKILL/taskkill, OOM) the stdin pipe
436
+ * closes, the helper hits EOF, clears the assertion, and exits — and
437
+ * Windows releases the thread's assertion on process death regardless.
438
+ * The helper therefore cannot outlive the proxy, so no orphan sweep is
439
+ * needed (unlike colbert/worktrees, which leave on-disk artifacts).
440
+ *
441
+ * Best-effort: every failure path (no powershell, Constrained Language
442
+ * Mode blocking `Add-Type`, spawn error) degrades to a clean no-op. We
443
+ * NEVER attempt to install, enable, or bypass language-mode/WDAC policy.
444
+ */
445
+ const ES_CONTINUOUS = 2147483648;
446
+ const ES_DISPLAY_REQUIRED = 2;
447
+ /** Default time to wait for the helper's `OK` readiness line.
448
+ *
449
+ * Sized for the slowest LEGITIMATE path, not the typical one. The script's
450
+ * `Add-Type -MemberDefinition` compiles a C# P/Invoke shim through csc.exe on
451
+ * first use, and that compile happens BEFORE the `OK` line is printed. On a
452
+ * cold machine (a fresh CI runner, a laptop that just booted, a host with
453
+ * aggressive on-access AV scanning) it routinely takes well over five seconds.
454
+ *
455
+ * CORRECTION to the rationale this constant shipped with. An earlier version of
456
+ * this comment (and the commit that raised the value) claimed a short timeout
457
+ * made the user "silently lose keep-awake". That is wrong, and a cross-lab
458
+ * reviewer caught it: `startKeepAwake` does nothing with `ready` but
459
+ * `consola.debug` it (`index.ts:128`). The handle is retained and the child
460
+ * keeps running, so the assertion is still set, just later than we observed it.
461
+ *
462
+ * The real cost of a tight value is therefore a LYING LOG, not a lost feature:
463
+ * we report "inactive" for a helper that is about to work. That is still worth
464
+ * fixing, because a false negative in a diagnostic is how you lose an afternoon
465
+ * chasing a working feature. Waiting longer costs nothing either way, since the
466
+ * readiness promise is awaited in the background and never blocks launch. */
467
+ const DEFAULT_READY_TIMEOUT_MS = 2e4;
468
+ /**
469
+ * The execution-state flags to assert. Always `ES_CONTINUOUS |
470
+ * ES_SYSTEM_REQUIRED` (machine stays awake); adds `ES_DISPLAY_REQUIRED`
471
+ * (screen stays on) when `displayRequired`. `>>> 0` forces an unsigned
472
+ * 32-bit value so the hex literal handed to PowerShell is positive.
473
+ */
474
+ function executionStateFlags(displayRequired) {
475
+ let flags = -2147483647;
476
+ if (displayRequired) flags |= ES_DISPLAY_REQUIRED;
477
+ return flags >>> 0;
478
+ }
479
+ /** Format a uint32 as a PowerShell `[uint32]<decimal>` literal.
480
+ *
481
+ * Decimal, NOT hex: in Windows PowerShell a hex literal like `0x80000001`
482
+ * parses as a *negative* Int32 (`-2147483647`) and fails to convert to
483
+ * the `uint` parameter ("Value was either too large or too small for a
484
+ * UInt32"). A decimal literal over Int32.MaxValue auto-promotes to a
485
+ * positive Int64, and the explicit `[uint32]` cast then fits. Verified
486
+ * against a real win32 host before shipping. */
487
+ function psUint32(n) {
488
+ return `[uint32]${n >>> 0}`;
489
+ }
490
+ /**
491
+ * Build the PowerShell script the persistent helper runs. PURE — the
492
+ * flag value is our own constant templated as a numeric literal, so
493
+ * there is no injection surface. The script:
494
+ * 1. P/Invokes `SetThreadExecutionState` with the requested flags.
495
+ * 2. Prints `OK` once the assertion succeeds (the readiness signal;
496
+ * no `OK` => `Add-Type` was CLM-blocked or the call returned 0).
497
+ * 3. Blocks reading stdin so it self-exits on parent death (pipe EOF).
498
+ * 4. Clears the assertion (`ES_CONTINUOUS` only) on the way out.
499
+ *
500
+ * The C# member-definition is a PowerShell SINGLE-quoted string so its
501
+ * embedded `"kernel32.dll"` double-quotes need no escaping.
502
+ */
503
+ function buildKeepAwakeScript(displayRequired) {
504
+ const assert = psUint32(executionStateFlags(displayRequired));
505
+ const clear = psUint32(ES_CONTINUOUS);
506
+ return [
507
+ `Add-Type -Name P -Namespace W -MemberDefinition '[System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint e);'`,
508
+ `if ([W.P]::SetThreadExecutionState(${assert}) -ne 0) { [Console]::Out.WriteLine('OK'); [Console]::Out.Flush() }`,
509
+ `while ($null -ne [Console]::In.ReadLine()) {}`,
510
+ `[void][W.P]::SetThreadExecutionState(${clear})`
511
+ ].join("\n");
512
+ }
513
+ /** The argv passed to powershell.exe (excluding the executable itself). */
514
+ function buildHelperArgs(displayRequired) {
515
+ return [
516
+ "-NoProfile",
517
+ "-NonInteractive",
518
+ "-Command",
519
+ buildKeepAwakeScript(displayRequired)
520
+ ];
521
+ }
522
+ /**
523
+ * Spawn the persistent helper. Returns a null handle (and `ready` →
524
+ * `false`) when powershell.exe can't be resolved or spawn throws — both
525
+ * clean no-op degradations. The helper's stdout is piped only to detect
526
+ * the `OK` readiness line; stderr is ignored.
527
+ */
528
+ function spawnHelper(opts) {
529
+ const ps = resolveExecutable("powershell.exe");
530
+ if (!ps) return {
531
+ handle: null,
532
+ ready: Promise.resolve(false)
533
+ };
534
+ let child;
535
+ try {
536
+ child = spawn(ps, buildHelperArgs(opts.displayRequired), {
537
+ stdio: [
538
+ "pipe",
539
+ "pipe",
540
+ "ignore"
541
+ ],
542
+ windowsHide: true,
543
+ shell: false
544
+ });
545
+ } catch {
546
+ return {
547
+ handle: null,
548
+ ready: Promise.resolve(false)
549
+ };
550
+ }
551
+ child.on("error", () => {});
552
+ return {
553
+ handle: { child },
554
+ ready: new Promise((resolve) => {
555
+ let settled = false;
556
+ let buf = "";
557
+ const done = (v) => {
558
+ if (settled) return;
559
+ settled = true;
560
+ clearTimeout(timer);
561
+ resolve(v);
562
+ };
563
+ const timer = setTimeout(() => done(false), opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
564
+ timer.unref?.();
565
+ child.stdout?.on("data", (c) => {
566
+ if (settled || buf.length > 256) return;
567
+ buf += c.toString("utf8");
568
+ if (buf.includes("OK")) done(true);
569
+ });
570
+ child.stdout?.on("error", () => {});
571
+ child.once("exit", () => done(false));
572
+ child.once("error", () => done(false));
573
+ })
574
+ };
575
+ }
576
+ /**
577
+ * Release the assertion: close the helper's stdin (→ pipe EOF → the
578
+ * helper clears `ES_*` and exits) then `taskkill /T /F` as a
579
+ * belt-and-suspenders reap. Windows also releases the assertion on the
580
+ * helper's process death regardless. Best-effort; never throws.
581
+ */
582
+ function killHelper(handle) {
583
+ const { child } = handle;
584
+ try {
585
+ child.stdin?.end();
586
+ } catch {}
587
+ try {
588
+ killManagedTree(child);
589
+ } catch {}
590
+ }
591
+ //#endregion
592
+ //#region src/lib/keep-awake/index.ts
593
+ /**
594
+ * Windows keep-awake — public entry points.
595
+ *
596
+ * `startKeepAwake()` is the fire-and-forget call the `start` / `claude` /
597
+ * `codex` launchers invoke after `setupAndServe` (alongside
598
+ * `provisionAndIndexColbert()` / `runSelfUpdate()`). On Windows, by
599
+ * default, it spawns a persistent PowerShell helper that holds a
600
+ * `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`
601
+ * assertion so the machine doesn't idle-sleep mid-session, and registers
602
+ * SIGINT/SIGTERM/exit handlers to release it.
603
+ *
604
+ * Best-effort and model-agnostic: non-win32 is a total no-op; any
605
+ * failure (no powershell, Constrained Language Mode blocking `Add-Type`)
606
+ * degrades to a clean debug-logged no-op. We NEVER install, enable, or
607
+ * bypass language-mode/WDAC policy, and never block or crash launch.
608
+ *
609
+ * Crash safety: the helper blocks on stdin, so if the proxy is hard-
610
+ * killed the pipe closes, the helper hits EOF and exits, and Windows
611
+ * releases the assertion on process death — no orphan is possible, so no
612
+ * boot-time sweep is needed.
613
+ */
614
+ /**
615
+ * True iff keep-awake should run THIS launch: win32 AND not opted out.
616
+ * Non-win32 short-circuits before anything else (no spawn, no flags read
617
+ * beyond the opt-out, no handler registration). `platform` is injectable
618
+ * for tests; production callers use the default `process.platform`.
619
+ */
620
+ function keepAwakeEnabled(platform = process$1.platform) {
621
+ return platform === "win32" && keepAwakeOptedIn();
622
+ }
623
+ let _handle = null;
624
+ let _started = false;
625
+ /** Synchronously release the assertion + drop the handle. Idempotent.
626
+ * Also clears the `_started` latch so a transient failure never
627
+ * permanently disables a later start. */
628
+ function releaseSync() {
629
+ const h = _handle;
630
+ _handle = null;
631
+ _started = false;
632
+ if (h) try {
633
+ killHelper(h);
634
+ } catch {}
635
+ }
636
+ let _registered = false;
637
+ let _exitHandler = null;
638
+ let _sigintHandler = null;
639
+ let _sigtermHandler = null;
640
+ /**
641
+ * Wire SIGINT/SIGTERM/exit handlers that release the assertion.
642
+ * Idempotent. The signal handlers re-raise after releasing (remove self
643
+ * + `process.kill(self)`) so Node's default terminate-on-signal is
644
+ * restored — otherwise merely attaching a listener cancels the default
645
+ * and Ctrl-C would clean but not exit. This is load-bearing for the
646
+ * `start` subcommand, which has no `launchChild`/`onShutdown` of its own.
647
+ */
648
+ function registerExitHandlers() {
649
+ if (_registered) return;
650
+ _registered = true;
651
+ _exitHandler = () => releaseSync();
652
+ _sigintHandler = () => {
653
+ releaseSync();
654
+ if (_sigintHandler) process$1.off("SIGINT", _sigintHandler);
655
+ process$1.kill(process$1.pid, "SIGINT");
656
+ };
657
+ _sigtermHandler = () => {
658
+ releaseSync();
659
+ if (_sigtermHandler) process$1.off("SIGTERM", _sigtermHandler);
660
+ process$1.kill(process$1.pid, "SIGTERM");
661
+ };
662
+ process$1.on("SIGINT", _sigintHandler);
663
+ process$1.on("SIGTERM", _sigtermHandler);
664
+ process$1.on("exit", _exitHandler);
665
+ }
666
+ /**
667
+ * Start keeping the machine awake. Synchronous, fire-and-forget,
668
+ * idempotent within a run. No-op off win32 or when opted out. Never
669
+ * throws.
670
+ */
671
+ function startKeepAwake() {
672
+ if (!keepAwakeEnabled()) return;
673
+ if (_started) return;
674
+ _started = true;
675
+ try {
676
+ const { handle, ready } = spawnHelper({ displayRequired: keepDisplayOn() });
677
+ if (!handle) {
678
+ _started = false;
679
+ consola.debug("keep-awake: inactive (powershell.exe not resolvable)");
680
+ return;
681
+ }
682
+ _handle = handle;
683
+ handle.child.once("exit", () => {
684
+ if (_handle === handle) _handle = null;
685
+ });
686
+ registerExitHandlers();
687
+ ready.then((ok) => {
688
+ consola.debug(ok ? "keep-awake: holding SetThreadExecutionState assertion (system sleep prevented)" : "keep-awake: inactive (helper did not confirm — Constrained Language Mode or PowerShell unavailable)");
689
+ });
690
+ } catch (err) {
691
+ _started = false;
692
+ _handle = null;
693
+ consola.debug("keep-awake: failed to start (continuing):", err);
694
+ }
695
+ }
696
+ /**
697
+ * Release the assertion / reap the helper. Idempotent; safe to `await`
698
+ * from a subcommand's `onShutdown` chain. Never throws.
699
+ */
700
+ async function stopKeepAwake() {
701
+ releaseSync();
702
+ }
703
+ //#endregion
704
+ //#region src/lib/first-mate/scheduler/autospawn.ts
705
+ /**
706
+ * Opt-in auto-spawn of the first-mate scheduler daemon as a SEPARATE process
707
+ * (a hung tick must not starve the proxy event loop).
708
+ *
709
+ * DEFAULT OFF — gated by GH_ROUTER_FM_DAEMON === "1" (explicit opt-in). The
710
+ * durable [fm-heartbeat] cron is the DEFAULT driver and the proven path; the
711
+ * daemon is experimental and NOT yet safe as the default because two hardening
712
+ * items remain (a verifier-stall wall-clock escalation, and an atomic-claim
713
+ * lock in durable-store) — flip the default only once those land.
714
+ *
715
+ * Requirements + honest boundaries:
716
+ * - The daemon entry is `scripts/first-mate-daemon.ts` (a TS source file run via
717
+ * `bun`); it is NOT in the published dist tarball, so auto-spawn only fires
718
+ * from a source checkout with `bun` available. If the script is absent we
719
+ * no-op (never a false "spawned" log).
720
+ * - Spawn failures NEVER crash the proxy: an async ENOENT (`bun` not on PATH)
721
+ * arrives as an `'error'` EVENT, not a sync throw, so we attach an `'error'`
722
+ * listener (without it the emitter re-throws → uncaughtException → exit(1)).
723
+ * - The returned handle MUST be killed by the caller on shutdown (no `detached`);
724
+ * an orphaned drive-primary daemon would keep merging PRs after the proxy is
725
+ * gone. The parent ALSO holds the child's stdin write end (`stdio[0]==="pipe"`)
726
+ * so it can EOF the child for a GRACEFUL stop (lease + pidfile released
727
+ * immediately, not by expiry) — the cross-platform teardown trigger, since an
728
+ * external SIGTERM on Windows is a hard kill the child can't observe. `kill()`
729
+ * stays the hard backstop so a wedged child is never orphaned (see
730
+ * `wireDaemonTeardown`).
731
+ * - It owns the deterministic drive loop only; live judgments still wake the
732
+ * lead via the heartbeat (no server->lead push).
733
+ */
734
+ function shouldAutoSpawnDaemon(env, agentsEnabled) {
735
+ if (!agentsEnabled) return false;
736
+ return env.GH_ROUTER_FM_DAEMON === "1";
737
+ }
738
+ /**
739
+ * The real (non-injected) spawner: node:child_process (not the Bun runtime's spawn) so the
740
+ * bundled dist/main.js stays node-loadable. No `detached` — the caller kills
741
+ * this on shutdown. stdin is "pipe" (parent holds the write end for a graceful
742
+ * EOF stop); stdout/stderr stay "ignore".
743
+ */
744
+ function nodeDaemonSpawn(cmd, spawnOpts, spawnImpl = spawn) {
745
+ const proc = spawnImpl(cmd[0], cmd.slice(1), {
746
+ env: spawnOpts.env,
747
+ stdio: spawnOpts.stdio
748
+ });
749
+ proc.on("error", (err) => consola.debug("first-mate daemon spawn error (ignored):", err));
750
+ return {
751
+ pid: proc.pid,
752
+ kill: () => void proc.kill("SIGKILL"),
753
+ endStdin: () => {
754
+ try {
755
+ proc.stdin?.end();
756
+ } catch {}
757
+ }
758
+ };
759
+ }
760
+ /**
761
+ * Spawn the daemon child if the opt-in gate passes AND the daemon script is
762
+ * present; else return undefined. Never throws, and never lets an async spawn
763
+ * error crash bootstrap.
764
+ */
765
+ function maybeSpawnDaemon(opts) {
766
+ const env = opts.env ?? process.env;
767
+ if (!shouldAutoSpawnDaemon(env, opts.agentsEnabled)) return void 0;
768
+ const script = path.join(opts.repoRoot ?? process.cwd(), "scripts/first-mate-daemon.ts");
769
+ if (opts.spawn === void 0 && !existsSync(script)) {
770
+ consola.debug(`first-mate daemon entry not found (${script}); auto-spawn skipped.`);
771
+ return;
772
+ }
773
+ try {
774
+ const child = (opts.spawn ?? ((cmd, spawnOpts) => nodeDaemonSpawn(cmd, spawnOpts)))(["bun", script], {
775
+ env,
776
+ stdio: [
777
+ "pipe",
778
+ "ignore",
779
+ "ignore"
780
+ ]
781
+ });
782
+ return {
783
+ pid: child.pid,
784
+ kill: child.kill,
785
+ endStdin: child.endStdin ?? (() => {})
786
+ };
787
+ } catch {
788
+ return;
789
+ }
790
+ }
791
+ /**
792
+ * Wire the proxy's teardown to shut the daemon child down GRACEFULLY, then hard
793
+ * kill as a backstop. Ordering (per teardown path):
794
+ * 1. `endStdin()` — EOF the child so it releases its lease/pidfile cleanly (the
795
+ * cross-platform trigger; on Windows an external SIGTERM would never run the
796
+ * child's handler, so this EOF is the ONLY graceful path there).
797
+ * 2. `kill()` — hard backstop so a wedged child is never orphaned (a prior
798
+ * review blocker: an orphaned drive-primary daemon keeps merging PRs).
799
+ *
800
+ * On SIGINT/SIGTERM the kill is deferred by a short, NON-BLOCKING, unref'd grace
801
+ * window (proxy exit is never delayed for it). On `'exit'` — where timers cannot
802
+ * run — we EOF then kill synchronously as a last resort. All steps are
803
+ * once-guarded and non-throwing.
804
+ */
805
+ function wireDaemonTeardown(handle, opts = {}) {
806
+ const proc = opts.proc ?? process;
807
+ const graceMs = opts.graceMs ?? 300;
808
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
809
+ let killed = false;
810
+ const kill = () => {
811
+ if (killed) return;
812
+ killed = true;
813
+ try {
814
+ handle.kill();
815
+ } catch {}
816
+ };
817
+ let ended = false;
818
+ const endStdin = () => {
819
+ if (ended) return;
820
+ ended = true;
821
+ try {
822
+ handle.endStdin();
823
+ } catch {}
824
+ };
825
+ const onSignal = () => {
826
+ endStdin();
827
+ const t = setTimer(kill, graceMs);
828
+ if (t && typeof t.unref === "function") t.unref();
829
+ };
830
+ proc.once("SIGINT", onSignal);
831
+ proc.once("SIGTERM", onSignal);
832
+ proc.once("exit", () => {
833
+ endStdin();
834
+ kill();
835
+ });
836
+ }
837
+ //#endregion
838
+ //#region src/lib/proxy.ts
839
+ function initProxyFromEnv() {
840
+ if (typeof Bun !== "undefined") return;
841
+ try {
842
+ const direct = new Agent();
843
+ const proxies = /* @__PURE__ */ new Map();
844
+ setGlobalDispatcher({
845
+ dispatch(options, handler) {
846
+ try {
847
+ const origin = typeof options.origin === "string" ? new URL(options.origin) : options.origin;
848
+ const raw = getProxyForUrl(origin.toString());
849
+ const proxyUrl = raw && raw.length > 0 ? raw : void 0;
850
+ if (!proxyUrl) {
851
+ consola.debug(`HTTP proxy bypass: ${origin.hostname}`);
852
+ return direct.dispatch(options, handler);
853
+ }
854
+ let agent = proxies.get(proxyUrl);
855
+ if (!agent) {
856
+ agent = new ProxyAgent(proxyUrl);
857
+ proxies.set(proxyUrl, agent);
858
+ }
859
+ let label = proxyUrl;
860
+ try {
861
+ const u = new URL(proxyUrl);
862
+ label = `${u.protocol}//${u.host}`;
863
+ } catch {}
864
+ consola.debug(`HTTP proxy route: ${origin.hostname} via ${label}`);
865
+ return agent.dispatch(options, handler);
866
+ } catch {
867
+ return direct.dispatch(options, handler);
868
+ }
869
+ },
870
+ close() {
871
+ return direct.close();
872
+ },
873
+ destroy() {
874
+ return direct.destroy();
875
+ }
876
+ });
877
+ consola.debug("HTTP proxy configured from environment (per-URL)");
878
+ } catch (err) {
879
+ consola.debug("Proxy setup skipped:", err);
880
+ }
881
+ }
882
+ //#endregion
883
+ //#region package.json
884
+ var name = "github-router";
885
+ var version = "0.3.255";
886
+ //#endregion
887
+ //#region src/lib/approval.ts
888
+ const awaitApproval = async () => {
889
+ if (!await consola.prompt(`Accept incoming request?`, { type: "confirm" })) throw new HTTPError("Request rejected by user", Response.json({ message: "Request rejected by user" }, { status: 403 }));
890
+ };
891
+ //#endregion
892
+ //#region src/lib/rate-limit.ts
893
+ const RATE_LIMIT_QUEUE_TIMEOUT_MS = 5e3;
894
+ let rateLimitChain = Promise.resolve();
895
+ async function checkRateLimit(state) {
896
+ if (state.rateLimitSeconds === void 0) return;
897
+ const ticket = { aborted: false };
898
+ const myTurn = rateLimitChain.then(() => doCheck(state, ticket));
899
+ rateLimitChain = myTurn.catch(() => {});
900
+ return Promise.race([myTurn, sleep(RATE_LIMIT_QUEUE_TIMEOUT_MS).then(() => {
901
+ ticket.aborted = true;
902
+ throw new HTTPError("Rate limit queue wait exceeded", Response.json({
903
+ type: "error",
904
+ error: {
905
+ type: "rate_limit_error",
906
+ message: `Rate limit queue exceeded ${RATE_LIMIT_QUEUE_TIMEOUT_MS}ms; try again`
907
+ }
908
+ }, { status: 429 }));
909
+ })]);
910
+ }
911
+ async function doCheck(state, ticket) {
912
+ if (state.rateLimitSeconds === void 0) return;
913
+ if (ticket.aborted) return;
914
+ const now = Date.now();
915
+ if (!state.lastRequestTimestamp) {
916
+ state.lastRequestTimestamp = now;
917
+ return;
918
+ }
919
+ const elapsedSeconds = (now - state.lastRequestTimestamp) / 1e3;
920
+ if (elapsedSeconds > state.rateLimitSeconds) {
921
+ state.lastRequestTimestamp = now;
922
+ return;
923
+ }
924
+ const waitTimeSeconds = Math.ceil(state.rateLimitSeconds - elapsedSeconds);
925
+ if (!state.rateLimitWait) {
926
+ consola.warn(`Rate limit exceeded. Need to wait ${waitTimeSeconds} more seconds.`);
927
+ throw new HTTPError("Rate limit exceeded", Response.json({ message: "Rate limit exceeded" }, { status: 429 }));
928
+ }
929
+ const waitTimeMs = waitTimeSeconds * 1e3;
930
+ consola.warn(`Rate limit reached. Waiting ${waitTimeSeconds} seconds before proceeding...`);
931
+ await sleep(waitTimeMs);
932
+ if (ticket.aborted) return;
933
+ state.lastRequestTimestamp = Date.now();
934
+ consola.info("Rate limit wait completed, proceeding with request");
935
+ }
936
+ //#endregion
937
+ //#region src/lib/request-log.ts
938
+ /**
939
+ * Format a number with K/M suffix for compact display.
940
+ */
941
+ function formatTokens(n) {
942
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
943
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
944
+ return String(n);
945
+ }
946
+ /**
947
+ * Build a context window summary: "in:1.2K out:50 ctx:1.2K/1M (0.1%)"
948
+ */
949
+ function formatTokenInfo(inputTokens, outputTokens, model) {
950
+ if (inputTokens === void 0) return void 0;
951
+ const parts = [];
952
+ const maxPrompt = model?.capabilities?.limits?.max_prompt_tokens;
953
+ if (maxPrompt) {
954
+ const pct = (inputTokens / maxPrompt * 100).toFixed(1);
955
+ parts.push(`in:${formatTokens(inputTokens)}/${formatTokens(maxPrompt)} (${pct}%)`);
956
+ } else parts.push(`in:${formatTokens(inputTokens)}`);
957
+ if (outputTokens !== void 0) parts.push(`out:${formatTokens(outputTokens)}`);
958
+ return parts.join(" ");
959
+ }
960
+ /**
961
+ * Print a single summary line for a completed request.
962
+ *
963
+ * Examples:
964
+ * POST /v1/messages claude-opus-4.6-1m in:1.2K/1M (0.1%) out:50 200 2.3s
965
+ * POST /v1/messages claude-opus-4-6→claude-opus-4.6-1m in:743/1M (0.1%) 200 198ms
966
+ * POST /v1/chat/completions claude-sonnet-4 in:15 out:16 200 2.1s stream
967
+ */
968
+ function logRequest(info, model, startTime) {
969
+ const parts = [];
970
+ parts.push(`${info.method} ${info.path}`);
971
+ if (info.resolvedModel && info.resolvedModel !== info.model) parts.push(`${info.model}→${info.resolvedModel}`);
972
+ else if (info.resolvedModel ?? info.model) parts.push(info.resolvedModel ?? info.model);
973
+ const tokenInfo = formatTokenInfo(info.inputTokens, info.outputTokens, model);
974
+ if (tokenInfo) parts.push(tokenInfo);
975
+ if (info.status !== void 0) parts.push(String(info.status));
976
+ const elapsed = Date.now() - startTime;
977
+ const duration = elapsed >= 1e3 ? `${(elapsed / 1e3).toFixed(1)}s` : `${elapsed}ms`;
978
+ parts.push(info.streaming ? `${duration} stream` : duration);
979
+ const line = parts.join(" ");
980
+ if (detectCapabilityMismatch(info, model)) consola.error(`[MISMATCH] ${line}`);
981
+ else consola.info(line);
982
+ }
983
+ /**
984
+ * Detect when the API rejects a request for token/context reasons
985
+ * that contradict what the /models endpoint reported.
986
+ */
987
+ function detectCapabilityMismatch(info, model) {
988
+ if (!info.errorBody || !model) return false;
989
+ if (!info.status || info.status < 400) return false;
990
+ const err = info.errorBody.toLowerCase();
991
+ return err.includes("token") || err.includes("context") || err.includes("too long") || err.includes("max_tokens") || err.includes("prompt is too long");
992
+ }
993
+ /**
994
+ * Opt-in instrumentation for the discovery loop (Phase 0.5 of the
995
+ * long-horizon plan). When `GH_ROUTER_LOG_FIELDS=1` is set in the
996
+ * environment, emits a single structured `[fields]` log line per request
997
+ * recording the top-level body keys, per-tool field keys, and
998
+ * anthropic-beta header values seen.
999
+ *
1000
+ * Default-off (zero overhead). The companion
1001
+ * `scripts/discover-new-fields.sh` greps these lines, aggregates unique
1002
+ * field names per request shape, and diffs against the known-fields
1003
+ * list in `docs/copilot-compat-matrix.md` — surfacing anything new
1004
+ * that should get a probe row added.
1005
+ *
1006
+ * Format (single line, deterministic-ish key order):
1007
+ * [fields] path=<P> body_keys=<csv> tool_field_keys=<csv> beta_values=<csv> agent=<id|->
1008
+ *
1009
+ * Where:
1010
+ * - `body_keys` is the alphabetical union of top-level keys in the
1011
+ * request body
1012
+ * - `tool_field_keys` is the alphabetical union of all keys appearing
1013
+ * across every entry of `body.tools[]` (or empty)
1014
+ * - `beta_values` is the comma-split anthropic-beta header value as
1015
+ * received (NOT filtered) — captures what the client sends, not
1016
+ * what we forward
1017
+ * - `agent` is Claude Code's `x-claude-code-agent-id` header, or `-` for
1018
+ * main-loop traffic. Without it a capture cannot tell a subagent request
1019
+ * from a main-loop one, which is exactly the question you need answered
1020
+ * when reasoning about per-agent request shape (does a subagent send a
1021
+ * `thinking` block?). The header value is opaque to us and is logged
1022
+ * verbatim.
1023
+ */
1024
+ function logRequestFields(opts) {
1025
+ if (process.env.GH_ROUTER_LOG_FIELDS !== "1") return;
1026
+ const bodyKeys = collectTopLevelKeys(opts.body);
1027
+ const toolFieldKeys = collectToolFieldKeys(opts.body);
1028
+ const betaValues = (opts.betaHeader ?? "").split(",").map((v) => v.trim()).filter(Boolean);
1029
+ consola.info(`[fields] path=${opts.path} body_keys=${bodyKeys.join(",")} tool_field_keys=${toolFieldKeys.join(",")} beta_values=${betaValues.join(",")} agent=${opts.agentId && opts.agentId.length > 0 ? opts.agentId : "-"}`);
1030
+ }
1031
+ function collectTopLevelKeys(body) {
1032
+ if (!body || typeof body !== "object" || Array.isArray(body)) return [];
1033
+ return Object.keys(body).sort();
1034
+ }
1035
+ function collectToolFieldKeys(body) {
1036
+ if (!body || typeof body !== "object") return [];
1037
+ const tools = body.tools;
1038
+ if (!Array.isArray(tools)) return [];
1039
+ const seen = /* @__PURE__ */ new Set();
1040
+ for (const tool of tools) if (tool && typeof tool === "object" && !Array.isArray(tool)) for (const k of Object.keys(tool)) seen.add(k);
1041
+ return [...seen].sort();
1042
+ }
1043
+ //#endregion
1044
+ //#region src/routes/chat-completions/handler.ts
1045
+ const ENCODER$1 = new TextEncoder();
1046
+ function formatSSE$1(chunk) {
1047
+ const parts = [];
1048
+ if (chunk.event) parts.push(`event: ${chunk.event}`);
1049
+ if (chunk.data !== void 0) for (const line of String(chunk.data).split(/\r\n|\r|\n/)) parts.push(`data: ${line}`);
1050
+ if (chunk.id !== void 0) parts.push(`id: ${String(chunk.id)}`);
1051
+ return parts.join("\n") + "\n\n";
1052
+ }
1053
+ async function handleCompletion$1(c) {
1054
+ const startTime = Date.now();
1055
+ await checkRateLimit(state);
1056
+ let payload = await c.req.json();
1057
+ const debugEnabled = consola.level >= 4;
1058
+ if (debugEnabled) consola.debug("Request payload:", JSON.stringify(payload).slice(-400));
1059
+ if (state.manualApprove) await awaitApproval();
1060
+ await injectWebSearchIfNeeded$1(payload);
1061
+ const originalModel = payload.model;
1062
+ const resolvedModel = resolveModel(payload.model);
1063
+ if (resolvedModel !== payload.model) payload.model = resolvedModel;
1064
+ const selectedModel = state.models?.data.find((model) => model.id === payload.model);
1065
+ logEndpointMismatch(payload.model, "/chat/completions");
1066
+ let inputTokens;
1067
+ try {
1068
+ if (selectedModel) inputTokens = (await getTokenCount(payload, selectedModel)).input;
1069
+ } catch {}
1070
+ if (isNullish(payload.max_tokens)) {
1071
+ payload = {
1072
+ ...payload,
1073
+ max_tokens: selectedModel?.capabilities?.limits?.max_output_tokens
1074
+ };
1075
+ if (debugEnabled) consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens));
1076
+ }
1077
+ const response = await createChatCompletions(payload, selectedModel?.requestHeaders, void 0, true).catch(async (error) => {
1078
+ if (error instanceof HTTPError) {
1079
+ const errorBody = await error.response.clone().text().catch(() => "");
1080
+ logRequest({
1081
+ method: "POST",
1082
+ path: c.req.path,
1083
+ model: originalModel,
1084
+ resolvedModel,
1085
+ status: error.response.status,
1086
+ errorBody
1087
+ }, selectedModel, startTime);
1088
+ }
1089
+ throw error;
1090
+ });
1091
+ const isStreaming = !isNonStreaming$1(response);
1092
+ const outputTokens = !isStreaming ? response.usage?.completion_tokens : void 0;
1093
+ logRequest({
1094
+ method: "POST",
1095
+ path: c.req.path,
1096
+ model: originalModel,
1097
+ resolvedModel,
1098
+ inputTokens,
1099
+ outputTokens,
1100
+ status: 200,
1101
+ streaming: isStreaming
1102
+ }, selectedModel, startTime);
1103
+ if (!isStreaming) {
1104
+ if (debugEnabled) consola.debug("Non-streaming response:", JSON.stringify(response));
1105
+ return c.json(response);
1106
+ }
1107
+ const iterator = response[Symbol.asyncIterator]();
1108
+ const firstResult = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
1109
+ if (firstResult.done) consola.warn(`Upstream /chat/completions returned an empty stream at ${c.req.path}`);
1110
+ let pendingFirstChunk = firstResult.done ? void 0 : firstResult.value;
1111
+ let upstreamFinished = firstResult.done;
1112
+ let consumerCancelled = false;
1113
+ const safeClose = (controller) => {
1114
+ try {
1115
+ controller.close();
1116
+ } catch {}
1117
+ };
1118
+ const releaseUpstream = (reason) => {
1119
+ if (typeof iterator.return === "function") iterator.return(reason).catch(() => {});
1120
+ };
1121
+ const safeEnqueue = (controller, bytes) => {
1122
+ try {
1123
+ controller.enqueue(bytes);
1124
+ return true;
1125
+ } catch (e) {
1126
+ if (isControllerClosedError(e)) {
1127
+ consumerCancelled = true;
1128
+ releaseUpstream(e);
1129
+ return false;
1130
+ }
1131
+ throw e;
1132
+ }
1133
+ };
1134
+ return new Response(new ReadableStream({
1135
+ async pull(controller) {
1136
+ if (consumerCancelled || upstreamFinished) {
1137
+ safeClose(controller);
1138
+ return;
1139
+ }
1140
+ if (pendingFirstChunk !== void 0) {
1141
+ const chunk = pendingFirstChunk;
1142
+ pendingFirstChunk = void 0;
1143
+ if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(chunk));
1144
+ safeEnqueue(controller, ENCODER$1.encode(formatSSE$1(chunk)));
1145
+ return;
1146
+ }
1147
+ try {
1148
+ const result = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
1149
+ if (consumerCancelled) {
1150
+ safeClose(controller);
1151
+ return;
1152
+ }
1153
+ if (result.done) {
1154
+ upstreamFinished = true;
1155
+ safeClose(controller);
1156
+ return;
1157
+ }
1158
+ if (result.value === void 0 || result.value === null) return;
1159
+ if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(result.value));
1160
+ safeEnqueue(controller, ENCODER$1.encode(formatSSE$1(result.value)));
1161
+ } catch (error) {
1162
+ upstreamFinished = true;
1163
+ if (consumerCancelled) {
1164
+ releaseUpstream(error);
1165
+ safeClose(controller);
1166
+ return;
1167
+ }
1168
+ const { errName, errMessage } = logStreamError(c.req.path, error);
1169
+ safeEnqueue(controller, ENCODER$1.encode(buildOpenAIErrorEvent(errName, errMessage)));
1170
+ releaseUpstream(error);
1171
+ safeClose(controller);
1172
+ }
1173
+ },
1174
+ cancel() {
1175
+ consumerCancelled = true;
1176
+ upstreamFinished = true;
1177
+ releaseUpstream();
1178
+ }
1179
+ }), {
1180
+ status: 200,
1181
+ headers: {
1182
+ "content-type": "text/event-stream",
1183
+ "cache-control": "no-cache",
1184
+ "transfer-encoding": "chunked",
1185
+ connection: "keep-alive"
1186
+ }
1187
+ });
1188
+ }
1189
+ const isNonStreaming$1 = (response) => Object.hasOwn(response, "choices");
1190
+ async function injectWebSearchIfNeeded$1(payload) {
1191
+ if (!payload.tools?.some((t) => "type" in t && t.type === "web_search" || t.function?.name === "web_search")) return;
1192
+ const query = payload.messages.some((msg) => msg.role === "tool") ? void 0 : extractUserQuery$2(payload.messages);
1193
+ if (query) try {
1194
+ const results = await searchWeb(query);
1195
+ const searchContext = [
1196
+ "[Web Search Results]",
1197
+ results.content,
1198
+ "",
1199
+ results.references.map((r) => `- [${r.title}](${r.url})`).join("\n"),
1200
+ "[End Web Search Results]"
1201
+ ].join("\n");
1202
+ const systemMsg = payload.messages.find((msg) => msg.role === "system");
1203
+ if (systemMsg) systemMsg.content = `${searchContext}\n\n${typeof systemMsg.content === "string" ? systemMsg.content : Array.isArray(systemMsg.content) ? systemMsg.content.filter((p) => p.type === "text").map((p) => "text" in p ? p.text : "").join("\n") : ""}`;
1204
+ else payload.messages.unshift({
1205
+ role: "system",
1206
+ content: searchContext
1207
+ });
1208
+ } catch (error) {
1209
+ consola.warn("Web search failed, continuing without results:", error);
1210
+ }
1211
+ payload.tools = payload.tools?.filter((t) => !("type" in t && t.type === "web_search" || t.function?.name === "web_search"));
1212
+ if (payload.tools?.length === 0) payload.tools = void 0;
1213
+ if (!payload.tools) payload.tool_choice = void 0;
1214
+ else if (payload.tool_choice && typeof payload.tool_choice === "object" && "type" in payload.tool_choice && payload.tool_choice.type === "function") {
1215
+ const toolChoiceName = payload.tool_choice.function?.name;
1216
+ if (toolChoiceName && !payload.tools.some((tool) => tool.function.name === toolChoiceName)) payload.tool_choice = void 0;
1217
+ }
1218
+ }
1219
+ function extractUserQuery$2(messages) {
1220
+ for (let i = messages.length - 1; i >= 0; i--) {
1221
+ const msg = messages[i];
1222
+ if (msg.role === "user") {
1223
+ if (typeof msg.content === "string") return msg.content;
1224
+ if (Array.isArray(msg.content)) {
1225
+ const text = msg.content.find((p) => p.type === "text");
1226
+ if (text && "text" in text) return text.text;
1227
+ }
1228
+ }
1229
+ }
1230
+ }
1231
+ //#endregion
1232
+ //#region src/routes/chat-completions/route.ts
1233
+ const completionRoutes = new Hono();
1234
+ completionRoutes.post("/", async (c) => {
1235
+ try {
1236
+ return await handleCompletion$1(c);
1237
+ } catch (error) {
1238
+ return await forwardError(c, error);
1239
+ }
1240
+ });
1241
+ //#endregion
1242
+ //#region src/services/copilot/create-embeddings.ts
1243
+ const createEmbeddings = async (payload) => {
1244
+ if (!state.copilotToken) throw new Error("Copilot token not found");
1245
+ const response = await fetch(`${copilotBaseUrl(state)}/embeddings`, {
1246
+ method: "POST",
1247
+ headers: copilotHeaders(state),
1248
+ body: JSON.stringify(payload)
1249
+ });
1250
+ if (!response.ok) throw new HTTPError("Failed to create embeddings", response);
1251
+ return await response.json();
1252
+ };
1253
+ //#endregion
1254
+ //#region src/routes/embeddings/route.ts
1255
+ const embeddingRoutes = new Hono();
1256
+ embeddingRoutes.post("/", async (c) => {
1257
+ try {
1258
+ const payload = await c.req.json();
1259
+ const response = await createEmbeddings(payload);
1260
+ return c.json(response);
1261
+ } catch (error) {
1262
+ return await forwardError(c, error);
1263
+ }
1264
+ });
1265
+ //#endregion
1266
+ //#region src/routes/mcp/route.ts
1267
+ const mcpRoutes = new Hono();
1268
+ mcpRoutes.post("/", async (c) => {
1269
+ try {
1270
+ return await handleMcpPost(c, "all");
1271
+ } catch (error) {
1272
+ return await forwardError(c, error);
1273
+ }
1274
+ });
1275
+ mcpRoutes.post("/:group", async (c) => {
1276
+ try {
1277
+ return await handleMcpPost(c, c.req.param("group"));
1278
+ } catch (error) {
1279
+ return await forwardError(c, error);
1280
+ }
1281
+ });
1282
+ mcpRoutes.delete("/", (c) => {
1283
+ try {
1284
+ return handleMcpDelete(c);
1285
+ } catch {
1286
+ return c.body(null, 500);
1287
+ }
1288
+ });
1289
+ mcpRoutes.delete("/:group", (c) => {
1290
+ try {
1291
+ return handleMcpDelete(c);
1292
+ } catch {
1293
+ return c.body(null, 500);
1294
+ }
1295
+ });
1296
+ //#endregion
1297
+ //#region src/lib/sanitize-anthropic-body.ts
1298
+ /**
1299
+ * Inbound /v1/messages body sanitizer.
1300
+ *
1301
+ * Today this only handles ADVISOR-related corruption — the
1302
+ * proxy-generated `server_tool_use{name:"advisor"}` and paired
1303
+ * `advisor_tool_result` blocks may travel through Claude Code's
1304
+ * persisted conversation state with a malformed `id`/`tool_use_id`
1305
+ * (e.g., a leftover `toolu_*` value from before the round-5 fix).
1306
+ * Every replay of that history through `/v1/messages` would 400 at
1307
+ * Copilot's spec validator without rewriting them on inbound. This
1308
+ * module performs the rewrite.
1309
+ *
1310
+ * **Out of scope** (deliberate, per gemini-critic round 6 — the "ID
1311
+ * round-trip trap"): generic `tool_use.id` and `tool_result.tool_use_id`
1312
+ * are stateful references between requests; rewriting them statelessly
1313
+ * would break Claude Code's client-side tool tracker. Only advisor
1314
+ * blocks are touched here, because both sides of an advisor pair
1315
+ * (`server_tool_use` + `advisor_tool_result`) are proxy-generated and
1316
+ * round-trip together in the same request body — so a per-request
1317
+ * deterministic transformation preserves pairing without cross-request
1318
+ * state.
1319
+ *
1320
+ * **Round-7 holistic fix**: Copilot rejects `server_tool_use{name:
1321
+ * "advisor"}` outright (spec validator says only `web_search`,
1322
+ * `tool_search_tool_regex`, `tool_search_tool_bm25` are allowed), so
1323
+ * fixing the id format alone isn't enough. The sanitizer also
1324
+ * **translates** historical advisor pairs to the
1325
+ * `tool_use{__anthropic_advisor}` + `tool_result` shape Copilot
1326
+ * accepts (per user direction "Option C"). Multi-turn split is
1327
+ * required because `tool_result` must appear in a `user` role per
1328
+ * Anthropic spec. The `__anthropic_advisor` tool definition is
1329
+ * re-injected into `tools[]` so the `tool_use.name` reference
1330
+ * resolves.
1331
+ */
1332
+ /**
1333
+ * Convert a `srvtoolu_*` id to the matching `toolu_*` id used in the
1334
+ * Copilot-replay shape (`tool_use.id` must match `^toolu_*$`). For
1335
+ * any other input shape, fall back to a synthesized `toolu_advisor_N`
1336
+ * id.
1337
+ */
1338
+ function toCopilotToolUseId(srvId, fallbackIndex) {
1339
+ if (srvId.startsWith("srvtoolu_")) {
1340
+ const suffix = srvId.slice(9);
1341
+ if (/^[a-zA-Z0-9_]+$/.test(suffix)) return `toolu_${suffix}`;
1342
+ }
1343
+ return `toolu_advisor_${fallbackIndex}`;
1344
+ }
1345
+ /**
1346
+ * Fast-path detector: returns true if the raw body has any chance of
1347
+ * needing sanitization. Avoids a full JSON parse for the common case
1348
+ * where the body is already spec-compliant.
1349
+ *
1350
+ * Looks for either an Anthropic-native advisor typed tool entry, or
1351
+ * any advisor-related block type that would need rewriting/
1352
+ * translating.
1353
+ */
1354
+ function bodyMightNeedSanitize(rawBody) {
1355
+ return rawBody.includes("\"server_tool_use\"") || rawBody.includes("\"advisor_tool_result\"") || /"type":"advisor_\d+"/.test(rawBody);
1356
+ }
1357
+ /**
1358
+ * Translate one assistant turn's content array, splitting at advisor
1359
+ * pairs into the multi-message structure Copilot accepts.
1360
+ *
1361
+ * Input shape (Claude Code stores everything in one assistant turn):
1362
+ * [text*, server_tool_use{advisor}, advisor_tool_result, text*, ...]
1363
+ *
1364
+ * Output: array of {role, content[]} message objects, alternating
1365
+ * assistant→user→assistant for each advisor pair encountered.
1366
+ */
1367
+ function splitAssistantTurnAtAdvisorPairs(originalContent, syntheticIndexRef) {
1368
+ const messages = [];
1369
+ let currentAssistantContent = [];
1370
+ let translated = false;
1371
+ let i = 0;
1372
+ while (i < originalContent.length) {
1373
+ const block = originalContent[i];
1374
+ const b = typeof block === "object" && block !== null ? block : null;
1375
+ if (b && b.type === "server_tool_use" && b.name === "__anthropic_advisor".replace(/^__anthropic_/, "")) {
1376
+ const stuId = typeof b.id === "string" ? b.id : "";
1377
+ const nextBlock = originalContent[i + 1];
1378
+ const next = typeof nextBlock === "object" && nextBlock !== null ? nextBlock : null;
1379
+ const copilotId = stuId.startsWith("srvtoolu_") ? toCopilotToolUseId(stuId, syntheticIndexRef.value++) : stuId.startsWith("toolu_") && /^toolu_[a-zA-Z0-9_]+$/.test(stuId) ? stuId : `toolu_advisor_${syntheticIndexRef.value++}`;
1380
+ currentAssistantContent.push({
1381
+ type: "tool_use",
1382
+ id: copilotId,
1383
+ name: ADVISOR_INTERNAL_TOOL_NAME,
1384
+ input: {}
1385
+ });
1386
+ messages.push({
1387
+ role: "assistant",
1388
+ content: currentAssistantContent
1389
+ });
1390
+ translated = true;
1391
+ let resultText = "";
1392
+ if (next && next.type === "advisor_tool_result") {
1393
+ const c = next.content;
1394
+ if (typeof c === "string") resultText = c;
1395
+ else if (typeof c === "object" && c !== null) {
1396
+ const txt = c.text;
1397
+ if (typeof txt === "string") resultText = txt;
1398
+ }
1399
+ i += 2;
1400
+ } else {
1401
+ resultText = "[Advisor result missing in conversation history.]";
1402
+ i += 1;
1403
+ }
1404
+ messages.push({
1405
+ role: "user",
1406
+ content: [{
1407
+ type: "tool_result",
1408
+ tool_use_id: copilotId,
1409
+ content: resultText
1410
+ }]
1411
+ });
1412
+ currentAssistantContent = [];
1413
+ continue;
1414
+ }
1415
+ if (b && b.type === "advisor_tool_result") {
1416
+ translated = true;
1417
+ i += 1;
1418
+ continue;
1419
+ }
1420
+ currentAssistantContent.push(block);
1421
+ i += 1;
1422
+ }
1423
+ if (currentAssistantContent.length > 0) messages.push({
1424
+ role: "assistant",
1425
+ content: currentAssistantContent
1426
+ });
1427
+ if (!translated) return {
1428
+ messages: [{
1429
+ role: "assistant",
1430
+ content: originalContent
1431
+ }],
1432
+ translated: false
1433
+ };
1434
+ return {
1435
+ messages,
1436
+ translated: true
1437
+ };
1438
+ }
1439
+ function sanitizeAnthropicBody(rawBody) {
1440
+ if (!bodyMightNeedSanitize(rawBody)) return rawBody;
1441
+ let parsed;
1442
+ try {
1443
+ parsed = JSON.parse(rawBody);
1444
+ } catch {
1445
+ return rawBody;
1446
+ }
1447
+ let mutated = false;
1448
+ if (Array.isArray(parsed.tools)) {
1449
+ const tools = parsed.tools;
1450
+ const before = tools.length;
1451
+ const filtered = tools.filter((t) => {
1452
+ if (typeof t !== "object" || t === null) return true;
1453
+ const type = t.type;
1454
+ return typeof type !== "string" || !type.startsWith("advisor_");
1455
+ });
1456
+ if (filtered.length !== before) {
1457
+ parsed.tools = filtered;
1458
+ mutated = true;
1459
+ }
1460
+ }
1461
+ if (Array.isArray(parsed.messages)) {
1462
+ const original = parsed.messages;
1463
+ const rebuilt = [];
1464
+ let anyTranslated = false;
1465
+ const syntheticIndexRef = { value: 0 };
1466
+ for (const msg of original) {
1467
+ if (typeof msg !== "object" || msg === null || msg.role !== "assistant") {
1468
+ rebuilt.push(msg);
1469
+ continue;
1470
+ }
1471
+ const content = msg.content;
1472
+ if (!Array.isArray(content)) {
1473
+ rebuilt.push(msg);
1474
+ continue;
1475
+ }
1476
+ if (!content.some((b) => {
1477
+ if (typeof b !== "object" || b === null) return false;
1478
+ const type = b.type;
1479
+ const name = b.name;
1480
+ return type === "server_tool_use" && name === "advisor" || type === "advisor_tool_result";
1481
+ })) {
1482
+ rebuilt.push(msg);
1483
+ continue;
1484
+ }
1485
+ const { messages: split, translated } = splitAssistantTurnAtAdvisorPairs(content, syntheticIndexRef);
1486
+ if (translated) {
1487
+ anyTranslated = true;
1488
+ for (const m of split) rebuilt.push(m);
1489
+ } else rebuilt.push(msg);
1490
+ }
1491
+ if (anyTranslated) {
1492
+ parsed.messages = rebuilt;
1493
+ mutated = true;
1494
+ const existingTools = Array.isArray(parsed.tools) ? parsed.tools : [];
1495
+ if (!existingTools.some((t) => {
1496
+ if (typeof t !== "object" || t === null) return false;
1497
+ return t.name === "__anthropic_advisor";
1498
+ })) parsed.tools = [...existingTools, {
1499
+ name: ADVISOR_INTERNAL_TOOL_NAME,
1500
+ description: ADVISOR_TOOL_INSTRUCTIONS,
1501
+ input_schema: {
1502
+ type: "object",
1503
+ properties: {},
1504
+ required: []
1505
+ }
1506
+ }];
1507
+ }
1508
+ }
1509
+ if (!mutated) return rawBody;
1510
+ return JSON.stringify(parsed);
1511
+ }
1512
+ //#endregion
1513
+ //#region src/lib/anthropic-translate/anthropic-sse.ts
1514
+ /**
1515
+ * Anthropic Messages SSE egress: frame builders + a Bun-safe ReadableStream
1516
+ * adapter that serializes a generator of Anthropic stream events to wire bytes.
1517
+ *
1518
+ * The synthesizer (see `responses-egress.ts`) is source-specific and yields
1519
+ * plain event objects; these builders + adapter own the Anthropic wire shape
1520
+ * and the stream lifecycle so the synthesizer stays a pure state machine.
1521
+ *
1522
+ * Stream-lifecycle contract (repo mandate for any synthesized SSE surface):
1523
+ * the adapter is pull-based (backpressure-respecting), guards every
1524
+ * `controller.enqueue`/`close` against the consumer-cancel race
1525
+ * (`isControllerClosedError`), and on a mid-stream upstream error emits a
1526
+ * terminal Anthropic `event: error` frame before closing — mirroring
1527
+ * `relayAnthropicStream`. Verified against Bun's
1528
+ * `TypeError: Invalid state: Controller is already closed`.
1529
+ */
1530
+ function makeMessageId() {
1531
+ return `msg_${randomUUID().replace(/-/g, "")}`;
1532
+ }
1533
+ function makeMessageStart(id, model, usage = {}) {
1534
+ return {
1535
+ type: "message_start",
1536
+ message: {
1537
+ id,
1538
+ type: "message",
1539
+ role: "assistant",
1540
+ model,
1541
+ content: [],
1542
+ stop_reason: null,
1543
+ stop_sequence: null,
1544
+ usage: {
1545
+ input_tokens: usage.input_tokens ?? 0,
1546
+ output_tokens: usage.output_tokens ?? 0,
1547
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
1548
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
1549
+ }
1550
+ }
1551
+ };
1552
+ }
1553
+ function makeContentBlockStart(index, block) {
1554
+ return {
1555
+ type: "content_block_start",
1556
+ index,
1557
+ content_block: block
1558
+ };
1559
+ }
1560
+ function makeTextDelta(index, text) {
1561
+ return {
1562
+ type: "content_block_delta",
1563
+ index,
1564
+ delta: {
1565
+ type: "text_delta",
1566
+ text
1567
+ }
1568
+ };
1569
+ }
1570
+ function makeInputJsonDelta(index, partialJson) {
1571
+ return {
1572
+ type: "content_block_delta",
1573
+ index,
1574
+ delta: {
1575
+ type: "input_json_delta",
1576
+ partial_json: partialJson
1577
+ }
1578
+ };
1579
+ }
1580
+ function makeThinkingDelta(index, thinking) {
1581
+ return {
1582
+ type: "content_block_delta",
1583
+ index,
1584
+ delta: {
1585
+ type: "thinking_delta",
1586
+ thinking
1587
+ }
1588
+ };
1589
+ }
1590
+ function makeSignatureDelta(index, signature) {
1591
+ return {
1592
+ type: "content_block_delta",
1593
+ index,
1594
+ delta: {
1595
+ type: "signature_delta",
1596
+ signature
1597
+ }
1598
+ };
1599
+ }
1600
+ function makeContentBlockStop(index) {
1601
+ return {
1602
+ type: "content_block_stop",
1603
+ index
1604
+ };
1605
+ }
1606
+ function makeMessageDelta(stopReason, stopSequence, usage) {
1607
+ return {
1608
+ type: "message_delta",
1609
+ delta: {
1610
+ stop_reason: stopReason,
1611
+ stop_sequence: stopSequence
1612
+ },
1613
+ usage: {
1614
+ input_tokens: usage.input_tokens ?? 0,
1615
+ output_tokens: usage.output_tokens ?? 0,
1616
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
1617
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
1618
+ }
1619
+ };
1620
+ }
1621
+ function makeMessageStop() {
1622
+ return { type: "message_stop" };
1623
+ }
1624
+ /** Serialize one event to the Anthropic SSE wire form (`event:` + `data:`). */
1625
+ function serializeAnthropicEvent(ev) {
1626
+ return `event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`;
1627
+ }
1628
+ /**
1629
+ * Wrap a generator of Anthropic stream events into a byte `ReadableStream`.
1630
+ * Pull-based; guards the consumer-cancel race on both `enqueue` and `close`;
1631
+ * converts a mid-stream generator throw into a terminal `event: error` frame.
1632
+ * On consumer cancel it invokes `onCancel` (abort the upstream fetch) and
1633
+ * `return()`s the generator so its `finally` tears down the upstream reader.
1634
+ */
1635
+ function anthropicSseStreamFromEvents(events, opts) {
1636
+ const enc = new TextEncoder();
1637
+ let consumerCancelled = false;
1638
+ let finished = false;
1639
+ const safeClose = (controller) => {
1640
+ try {
1641
+ controller.close();
1642
+ } catch {}
1643
+ };
1644
+ return new ReadableStream({
1645
+ async pull(controller) {
1646
+ if (consumerCancelled || finished) {
1647
+ safeClose(controller);
1648
+ return;
1649
+ }
1650
+ let res;
1651
+ try {
1652
+ res = await events.next();
1653
+ } catch (err) {
1654
+ finished = true;
1655
+ if (consumerCancelled) {
1656
+ safeClose(controller);
1657
+ return;
1658
+ }
1659
+ const name = err instanceof Error ? err.name : "Error";
1660
+ const message = err instanceof Error ? err.message : String(err);
1661
+ consola.error(`Anthropic-translate stream interrupted at ${opts.routePath}: ${name}: ${JSON.stringify(message)}`);
1662
+ try {
1663
+ controller.enqueue(enc.encode(buildAnthropicErrorEvent(name, message)));
1664
+ } catch (enqueueError) {
1665
+ if (!isControllerClosedError(enqueueError)) consola.warn(`Could not deliver error event to consumer at ${opts.routePath}: ${enqueueError instanceof Error ? enqueueError.message : String(enqueueError)}`);
1666
+ }
1667
+ safeClose(controller);
1668
+ return;
1669
+ }
1670
+ if (consumerCancelled) {
1671
+ safeClose(controller);
1672
+ return;
1673
+ }
1674
+ if (res.done) {
1675
+ finished = true;
1676
+ safeClose(controller);
1677
+ return;
1678
+ }
1679
+ try {
1680
+ controller.enqueue(enc.encode(serializeAnthropicEvent(res.value)));
1681
+ } catch (err) {
1682
+ if (isControllerClosedError(err)) {
1683
+ consumerCancelled = true;
1684
+ return;
1685
+ }
1686
+ throw err;
1687
+ }
1688
+ },
1689
+ cancel() {
1690
+ consumerCancelled = true;
1691
+ finished = true;
1692
+ opts.onCancel?.();
1693
+ events.return?.(void 0);
1694
+ }
1695
+ });
1696
+ }
1697
+ //#endregion
1698
+ //#region src/lib/reasoning-effort.ts
1699
+ /**
1700
+ * Reasoning-effort bucketing + clamping shared by the `/v1/messages` handler
1701
+ * (adaptive-thinking translation) and the Anthropic-translation shim
1702
+ * (thinking-budget → Responses `reasoning.effort`).
1703
+ *
1704
+ * Extracted from `src/routes/messages/handler.ts` so a `~/lib/*` module can
1705
+ * depend on it without importing route code (and without forming a
1706
+ * handler → shim → handler import cycle). `handler.ts` re-exports these for
1707
+ * backward compatibility with existing imports/tests.
1708
+ */
1709
+ const EFFORT_ORDER = [
1710
+ "low",
1711
+ "medium",
1712
+ "high",
1713
+ "xhigh"
1714
+ ];
1715
+ /**
1716
+ * Bucket a thinking budget into a Copilot reasoning-effort string.
1717
+ * `<2000`→low, `<8000`→medium, `<24000`→high, else→xhigh.
1718
+ * Defaults missing/non-numeric budgets to 8000 ("high").
1719
+ */
1720
+ function bucketEffort(budget) {
1721
+ const n = typeof budget === "number" && Number.isFinite(budget) ? budget : 8e3;
1722
+ if (n < 2e3) return "low";
1723
+ if (n < 8e3) return "medium";
1724
+ if (n < 24e3) return "high";
1725
+ return "xhigh";
1726
+ }
1727
+ /**
1728
+ * Clamp a bucketed effort to the closest value in `supported`. Ties resolve to
1729
+ * the lower-tier option (per EFFORT_ORDER).
1730
+ *
1731
+ * Iterates EFFORT_ORDER (canonical low→xhigh) so the first match on a given
1732
+ * distance is always the lower-tier value, regardless of input order in
1733
+ * `supported`.
1734
+ */
1735
+ function clampEffort(bucketed, supported) {
1736
+ if (supported.includes(bucketed)) return bucketed;
1737
+ const targetIdx = EFFORT_ORDER.indexOf(bucketed);
1738
+ let best;
1739
+ let bestDist = Infinity;
1740
+ for (let i = 0; i < EFFORT_ORDER.length; i++) {
1741
+ const value = EFFORT_ORDER[i];
1742
+ if (!supported.includes(value)) continue;
1743
+ const dist = Math.abs(i - targetIdx);
1744
+ if (dist < bestDist) {
1745
+ bestDist = dist;
1746
+ best = value;
1747
+ }
1748
+ }
1749
+ return best ?? bucketed;
1750
+ }
1751
+ //#endregion
1752
+ //#region src/lib/anthropic-translate/anthropic-request.ts
1753
+ /**
1754
+ * Steering appended to `instructions` for shim-routed (non-Claude) models when
1755
+ * the request carries Claude Code's native file tools. gpt-5.5 and other
1756
+ * OpenAI/Gemini-lineage models receive the Edit/Write tool definitions verbatim
1757
+ * (the shim never mangles them), but their base prior is to script file ops in
1758
+ * Python/Bash rather than call the dedicated tools. Claude models never reach
1759
+ * this code path (they fall through to the /v1/messages passthrough), so this is
1760
+ * automatically scoped to the models that need the nudge. Strong PREFERENCE, not
1761
+ * a Bash ban — running builds/tests/git still belongs in Bash.
1762
+ */
1763
+ const FILE_TOOL_GUIDANCE = `<file_tools>
1764
+ You have dedicated tools for files: use Read to read a file, Edit to modify an existing file, and Write to create one. Prefer them over shell for reading or editing. Do NOT shell out (cat, sed, awk, echo >, here-docs, or python/one-off scripts) to read, search, or rewrite file contents when a dedicated tool exists — the dedicated tools are safer and produce reviewable diffs. Use Grep/Glob to search rather than shell grep/find. Reserve Bash for commands that have no dedicated tool: builds, tests, git, package managers, and running programs.
1765
+ </file_tools>`;
1766
+ /**
1767
+ * Append `FILE_TOOL_GUIDANCE` to the flattened system `instructions` iff the
1768
+ * request carries Claude Code's canonical `Edit` or `Write` tool. The exact
1769
+ * capitalized-name match is deliberately precise: it fires for a Claude Code
1770
+ * editing session but not for arbitrary MCP tools like `write_file`, and not for
1771
+ * non-editing chats (so a plain gpt-5.5 conversation is not polluted). The block
1772
+ * is appended AFTER the existing instructions (end-of-prompt recency) and the
1773
+ * original system text is preserved, never replaced. Opt out with
1774
+ * `GH_ROUTER_DISABLE_SHIM_TOOL_STEERING=1`.
1775
+ */
1776
+ function appendFileToolGuidance(instructions, tools) {
1777
+ if (parseBoolEnv(process.env.GH_ROUTER_DISABLE_SHIM_TOOL_STEERING) === true) return instructions;
1778
+ if (!tools?.some((t) => t.name === "Edit" || t.name === "Write")) return instructions;
1779
+ return instructions && instructions.length > 0 ? `${instructions}\n\n${FILE_TOOL_GUIDANCE}` : FILE_TOOL_GUIDANCE;
1780
+ }
1781
+ /** Flatten Anthropic `system` (string | array of text blocks) into a string. */
1782
+ function flattenSystem(system) {
1783
+ if (typeof system === "string") return system.length > 0 ? system : void 0;
1784
+ if (Array.isArray(system)) {
1785
+ let s = "";
1786
+ for (const block of system) if (block && typeof block === "object" && block.type === "text") {
1787
+ const t = block.text;
1788
+ if (typeof t === "string") s += t;
1789
+ }
1790
+ return s.length > 0 ? s : void 0;
1791
+ }
1792
+ }
1793
+ /**
1794
+ * Parse an Anthropic `tool_result.content` (string | block array) into the
1795
+ * plain-text `output` for the Responses `function_call_output` (a string-only
1796
+ * item) PLUS any image parts found in the content. A `function_call_output`
1797
+ * cannot carry images, so the caller emits the extracted images as a follow-up
1798
+ * user message (Claude Code browser screenshots/observations arrive this way).
1799
+ * `isError` (the tool_result `is_error` flag) is preserved by prefixing the
1800
+ * text so the model still learns the tool call failed.
1801
+ */
1802
+ function parseToolResultContent(content, isError) {
1803
+ const images = [];
1804
+ let text = "";
1805
+ if (typeof content === "string") text = content;
1806
+ else if (Array.isArray(content)) for (const block of content) {
1807
+ if (!block || typeof block !== "object") continue;
1808
+ const b = block;
1809
+ if (b.type === "text" && typeof b.text === "string") text += b.text;
1810
+ else if (b.type === "image") {
1811
+ const img = anthropicImageToNeutral(b.source);
1812
+ if (img) images.push(img);
1813
+ }
1814
+ }
1815
+ if (images.length > 0 && text.length === 0) text = "[image result below]";
1816
+ if (isError) text = text.length > 0 ? `[tool error] ${text}` : "[tool error]";
1817
+ return {
1818
+ output: text,
1819
+ images
1820
+ };
1821
+ }
1822
+ /** Map an Anthropic `image` block source to a neutral image part. */
1823
+ function anthropicImageToNeutral(source) {
1824
+ if (!source || typeof source !== "object") return null;
1825
+ if (source.type === "url" && typeof source.url === "string") return {
1826
+ type: "image",
1827
+ url: source.url
1828
+ };
1829
+ if (source.type === "base64" && typeof source.data === "string") return {
1830
+ type: "image",
1831
+ mimeType: typeof source.media_type === "string" ? source.media_type : "image/png",
1832
+ data: source.data
1833
+ };
1834
+ return null;
1835
+ }
1836
+ /** Concatenate the text of an Anthropic `document` `content`-source block array. */
1837
+ function joinDocumentContentText(content) {
1838
+ if (!Array.isArray(content)) return "";
1839
+ let text = "";
1840
+ for (const block of content) if (block && typeof block === "object") {
1841
+ const b = block;
1842
+ if (b.type === "text" && typeof b.text === "string") text += b.text;
1843
+ }
1844
+ return text;
1845
+ }
1846
+ /**
1847
+ * Map an Anthropic `document` block to a neutral content part.
1848
+ * - base64 source → neutral `document` (mimeType + data) → Responses
1849
+ * `input_file` with `file_data`; on the chat path → an inline text note
1850
+ * (Copilot's `/chat/completions` rejects file parts).
1851
+ * - url source → neutral `document` (url) → Responses `input_file.file_url`.
1852
+ * - text source (a plain-text document) → the doc's text folded into a `text`
1853
+ * part, so the model sees it on BOTH paths.
1854
+ * - content source (content-block document) → its text blocks folded into a
1855
+ * `text` part.
1856
+ * Missing/invalid fields (unknown source type, `file`-id references Copilot has
1857
+ * no Files API for, empty text) yield null and are dropped.
1858
+ */
1859
+ function anthropicDocumentToNeutral(b) {
1860
+ const source = b.source;
1861
+ if (!source || typeof source !== "object") return null;
1862
+ const s = source;
1863
+ const filename = typeof b.title === "string" && b.title.length > 0 ? b.title : "document.pdf";
1864
+ if (s.type === "base64" && typeof s.data === "string") return {
1865
+ type: "document",
1866
+ mimeType: typeof s.media_type === "string" ? s.media_type : "application/pdf",
1867
+ data: s.data,
1868
+ filename
1869
+ };
1870
+ if (s.type === "url" && typeof s.url === "string") return {
1871
+ type: "document",
1872
+ url: s.url,
1873
+ filename
1874
+ };
1875
+ if (s.type === "text" && typeof s.data === "string") return s.data.length > 0 ? {
1876
+ type: "text",
1877
+ text: s.data
1878
+ } : null;
1879
+ if (s.type === "content") {
1880
+ const text = joinDocumentContentText(s.content);
1881
+ return text.length > 0 ? {
1882
+ type: "text",
1883
+ text
1884
+ } : null;
1885
+ }
1886
+ return null;
1887
+ }
1888
+ /**
1889
+ * Convert one Anthropic message into zero-or-more neutral messages. A user
1890
+ * message with `tool_result` blocks fans out: text/image content becomes a
1891
+ * user message and each tool_result becomes its own `toolResult` message,
1892
+ * emitted in wire order so a function_call_output never precedes its text.
1893
+ */
1894
+ function anthropicMessageToNeutral(msg) {
1895
+ const role = msg.role;
1896
+ const content = msg.content;
1897
+ if (role === "assistant") {
1898
+ const parts = [];
1899
+ if (typeof content === "string") {
1900
+ if (content.length > 0) parts.push({
1901
+ type: "text",
1902
+ text: content
1903
+ });
1904
+ } else if (Array.isArray(content)) for (const block of content) {
1905
+ if (!block || typeof block !== "object") continue;
1906
+ const b = block;
1907
+ if (b.type === "text" && typeof b.text === "string") parts.push({
1908
+ type: "text",
1909
+ text: b.text
1910
+ });
1911
+ else if (b.type === "tool_use") parts.push({
1912
+ type: "toolCall",
1913
+ id: typeof b.id === "string" ? b.id : "",
1914
+ name: typeof b.name === "string" ? b.name : "",
1915
+ arguments: b.input ?? {}
1916
+ });
1917
+ }
1918
+ return [{
1919
+ role: "assistant",
1920
+ content: parts
1921
+ }];
1922
+ }
1923
+ const out = [];
1924
+ let userParts = [];
1925
+ const flushUser = () => {
1926
+ if (userParts.length === 0) return;
1927
+ out.push({
1928
+ role: "user",
1929
+ content: userParts
1930
+ });
1931
+ userParts = [];
1932
+ };
1933
+ if (typeof content === "string") {
1934
+ if (content.length > 0) out.push({
1935
+ role: "user",
1936
+ content
1937
+ });
1938
+ return out;
1939
+ }
1940
+ if (Array.isArray(content)) for (const block of content) {
1941
+ if (!block || typeof block !== "object") continue;
1942
+ const b = block;
1943
+ if (b.type === "text" && typeof b.text === "string") userParts.push({
1944
+ type: "text",
1945
+ text: b.text
1946
+ });
1947
+ else if (b.type === "image") {
1948
+ const img = anthropicImageToNeutral(b.source);
1949
+ if (img) userParts.push(img);
1950
+ } else if (b.type === "document") {
1951
+ const doc = anthropicDocumentToNeutral(b);
1952
+ if (doc) userParts.push(doc);
1953
+ } else if (b.type === "tool_result") {
1954
+ flushUser();
1955
+ const { output, images } = parseToolResultContent(b.content, b.is_error === true);
1956
+ out.push({
1957
+ role: "toolResult",
1958
+ toolCallId: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
1959
+ output
1960
+ });
1961
+ if (images.length > 0) out.push({
1962
+ role: "user",
1963
+ content: images
1964
+ });
1965
+ }
1966
+ }
1967
+ flushUser();
1968
+ return out;
1969
+ }
1970
+ function parseTools(tools) {
1971
+ if (!Array.isArray(tools) || tools.length === 0) return void 0;
1972
+ const out = [];
1973
+ for (const tool of tools) {
1974
+ if (!tool || typeof tool !== "object") continue;
1975
+ const t = tool;
1976
+ if (typeof t.name !== "string" || t.name.length === 0) continue;
1977
+ const schema = t.input_schema ?? t.parameters;
1978
+ out.push({
1979
+ name: t.name,
1980
+ description: typeof t.description === "string" ? t.description : void 0,
1981
+ parameters: schema && typeof schema === "object" ? schema : {
1982
+ type: "object",
1983
+ properties: {}
1984
+ }
1985
+ });
1986
+ }
1987
+ return out.length > 0 ? out : void 0;
1988
+ }
1989
+ function parseToolChoice(toolChoice) {
1990
+ if (!toolChoice || typeof toolChoice !== "object") return void 0;
1991
+ const tc = toolChoice;
1992
+ switch (tc.type) {
1993
+ case "auto": return "auto";
1994
+ case "any": return "required";
1995
+ case "none": return "none";
1996
+ case "tool": return typeof tc.name === "string" && tc.name.length > 0 ? {
1997
+ type: "function",
1998
+ name: tc.name
1999
+ } : void 0;
2000
+ default: return;
2001
+ }
2002
+ }
2003
+ /**
2004
+ * Anthropic carries `disable_parallel_tool_use` on the `tool_choice` object.
2005
+ * Returns `false` (the wire signal to disable parallel tool calls) only when it
2006
+ * is explicitly `true`; `undefined` otherwise, so the payload builders omit the
2007
+ * field rather than ever sending `parallel_tool_calls: true`.
2008
+ */
2009
+ function parseDisableParallelToolUse(toolChoice) {
2010
+ if (!toolChoice || typeof toolChoice !== "object") return void 0;
2011
+ return toolChoice.disable_parallel_tool_use === true ? false : void 0;
2012
+ }
2013
+ /** Default absent Anthropic `thinking` to an effort, clamped by the model.
2014
+ * Every shim model defaults to `high`, so a client level maps to the identical
2015
+ * provider level (low→low, medium→medium, high→high, xhigh→xhigh) and `high` is
2016
+ * the only value the router injects on its own. There is deliberately NO floor:
2017
+ * an explicit client budget that buckets to `medium` yields `medium`, because a
2018
+ * one-directional max() would mean the router silently overriding the level the
2019
+ * user chose.
2020
+ *
2021
+ * Opt in to the previous behavior (xhigh for the OpenAI frontier models when no
2022
+ * thinking is sent) with `GH_ROUTER_FRONTIER_XHIGH_DEFAULT=1`. This replaces the
2023
+ * old opt-OUT `GH_ROUTER_DISABLE_FRONTIER_XHIGH_DEFAULT`, whose meaning would
2024
+ * have inverted under the new default.
2025
+ *
2026
+ * Two honest limits, both pre-existing: the level is bucketed from a token
2027
+ * budget so the mapping is lossy at the boundaries, and `clampEffort` moves a
2028
+ * level the model does not advertise (gemini has no `xhigh`). Returns undefined
2029
+ * for a model that advertises NO `reasoning_effort` allowlist, which leaves the
2030
+ * provider's own default rather than `high` — forcing an effort there could 400.
2031
+ * An explicit client `thinking` budget is handled by parseReasoningEffort and is
2032
+ * NOT affected by this default. */
2033
+ function defaultReasoningEffort(model) {
2034
+ const supported = model?.capabilities?.supports?.reasoning_effort;
2035
+ if (!(Array.isArray(supported) && supported.length > 0)) return void 0;
2036
+ return clampEffort(parseBoolEnv(process.env.GH_ROUTER_FRONTIER_XHIGH_DEFAULT) === true && model?.id != null && shimDefaultsToXhigh(model.id) && supported.includes("xhigh") ? "xhigh" : "high", supported);
2037
+ }
2038
+ /**
2039
+ * Map Anthropic `thinking` to a Responses reasoning effort, clamped to the
2040
+ * model's `reasoning_effort` allowlist. Returns undefined when thinking is
2041
+ * absent/disabled/non-enabled; the absent default is applied at the call site.
2042
+ */
2043
+ function parseReasoningEffort(thinking, model) {
2044
+ if (!thinking || typeof thinking !== "object") return void 0;
2045
+ const t = thinking;
2046
+ if (t.type !== "enabled") return void 0;
2047
+ const bucketed = bucketEffort(t.budget_tokens);
2048
+ const supported = model?.capabilities?.supports?.reasoning_effort;
2049
+ return Array.isArray(supported) && supported.length > 0 ? clampEffort(bucketed, supported) : bucketed;
2050
+ }
2051
+ /**
2052
+ * Parse an already-JSON-parsed Anthropic Messages body into the neutral shape.
2053
+ * `resolvedModel` is the catalog id the request will run on; `model` its
2054
+ * catalog entry (for the reasoning-effort allowlist).
2055
+ */
2056
+ function parseAnthropicRequest(body, resolvedModel, model) {
2057
+ const messages = [];
2058
+ if (Array.isArray(body.messages)) {
2059
+ for (const msg of body.messages) if (msg && typeof msg === "object") for (const n of anthropicMessageToNeutral(msg)) messages.push(n);
2060
+ }
2061
+ const maxTokens = typeof body.max_tokens === "number" && body.max_tokens > 0 ? body.max_tokens : void 0;
2062
+ const stopSequences = Array.isArray(body.stop_sequences) ? body.stop_sequences.filter((s) => typeof s === "string") : void 0;
2063
+ const tools = parseTools(body.tools);
2064
+ return {
2065
+ model: resolvedModel,
2066
+ instructions: appendFileToolGuidance(flattenSystem(body.system), tools),
2067
+ messages,
2068
+ tools,
2069
+ toolChoice: parseToolChoice(body.tool_choice),
2070
+ parallelToolCalls: parseDisableParallelToolUse(body.tool_choice),
2071
+ reasoningEffort: body.thinking === void 0 ? defaultReasoningEffort(model) : parseReasoningEffort(body.thinking, model),
2072
+ maxOutputTokens: maxTokens,
2073
+ stopSequences: stopSequences && stopSequences.length > 0 ? stopSequences : void 0,
2074
+ stream: body.stream === true
2075
+ };
2076
+ }
2077
+ /** Build the Copilot `/responses` payload from a parsed Anthropic request. */
2078
+ function parsedToResponsesPayload(parsed) {
2079
+ return assembleResponsesPayload({
2080
+ model: parsed.model,
2081
+ instructions: parsed.instructions,
2082
+ messages: parsed.messages,
2083
+ tools: parsed.tools,
2084
+ toolChoice: parsed.toolChoice,
2085
+ reasoningEffort: parsed.reasoningEffort,
2086
+ maxOutputTokens: parsed.maxOutputTokens,
2087
+ stopSequences: parsed.stopSequences,
2088
+ parallelToolCalls: parsed.parallelToolCalls,
2089
+ stream: parsed.stream
2090
+ });
2091
+ }
2092
+ //#endregion
2093
+ //#region src/lib/anthropic-translate/chat-request.ts
2094
+ /** Build the `data:` URI (base64) or return the verbatim URL for an image part. */
2095
+ function imageUrlFor(part) {
2096
+ if (typeof part.url === "string" && part.url.length > 0) return part.url;
2097
+ return `data:${part.mimeType ?? "image/png"};base64,${part.data ?? ""}`;
2098
+ }
2099
+ /**
2100
+ * A brief inline note standing in for a document on the chat path. Copilot's
2101
+ * `/chat/completions` rejects file content parts (`type` must be `image_url` or
2102
+ * `text` → HTTP 400), so a document (PDF) can't be forwarded to a Gemini model;
2103
+ * the note keeps the document from being silently dropped and tells the model
2104
+ * one was provided but is unavailable, instead of 400ing the request.
2105
+ *
2106
+ * The note is wrapped in leading + trailing newlines so it is always DELIMITED
2107
+ * from adjacent user text — in the string-collapse branch it can't glue onto a
2108
+ * neighboring text run (`...[model]what is this?`), and in the content-parts
2109
+ * branch it stands as its own line. Regular text-to-text concatenation is left
2110
+ * untouched (only the note carries the delimiter), so wire order is preserved.
2111
+ */
2112
+ function documentNote(part) {
2113
+ return `\n[document "${part.filename ?? "document"}" attached but not supported for this model]\n`;
2114
+ }
2115
+ /**
2116
+ * A user turn: plain string when there are no images; otherwise OpenAI content
2117
+ * parts (`text` / `image_url`). Mirrors `neutralUserToResponses` so the two
2118
+ * shims encode a multimodal user turn the same way.
2119
+ */
2120
+ function neutralUserToChat(m) {
2121
+ if (typeof m.content === "string") return {
2122
+ role: "user",
2123
+ content: m.content
2124
+ };
2125
+ if (!m.content.some((c) => c.type === "image")) {
2126
+ let text = "";
2127
+ for (const c of m.content) if (c.type === "text") text += c.text;
2128
+ else if (c.type === "document") text += documentNote(c);
2129
+ return {
2130
+ role: "user",
2131
+ content: text
2132
+ };
2133
+ }
2134
+ const parts = [];
2135
+ for (const c of m.content) if (c.type === "text") parts.push({
2136
+ type: "text",
2137
+ text: c.text
2138
+ });
2139
+ else if (c.type === "image") parts.push({
2140
+ type: "image_url",
2141
+ image_url: { url: imageUrlFor(c) }
2142
+ });
2143
+ else if (c.type === "document") parts.push({
2144
+ type: "text",
2145
+ text: documentNote(c)
2146
+ });
2147
+ return {
2148
+ role: "user",
2149
+ content: parts
2150
+ };
2151
+ }
2152
+ /**
2153
+ * An assistant turn: text parts collapse into `content`, tool_use parts become
2154
+ * OpenAI `tool_calls`. Unlike the Responses path (which flushes text before each
2155
+ * call to preserve interleaving), the chat wire shape carries all text on
2156
+ * `content` and all calls on `tool_calls`, so ordering within the turn is not
2157
+ * representable — matching how OpenAI itself echoes an assistant turn. When the
2158
+ * turn is tool-calls-only, `content` is `null` (OpenAI convention).
2159
+ */
2160
+ function neutralAssistantToChat(m) {
2161
+ let text = "";
2162
+ const toolCalls = [];
2163
+ for (const c of m.content) if (c.type === "text") text += c.text;
2164
+ else if (c.type === "toolCall") toolCalls.push({
2165
+ id: c.id,
2166
+ type: "function",
2167
+ function: {
2168
+ name: c.name,
2169
+ arguments: JSON.stringify(c.arguments ?? {})
2170
+ }
2171
+ });
2172
+ const msg = {
2173
+ role: "assistant",
2174
+ content: text.length > 0 ? text : toolCalls.length > 0 ? null : ""
2175
+ };
2176
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
2177
+ return msg;
2178
+ }
2179
+ /** Translate one neutral message into a single chat/completions message. */
2180
+ function neutralMessageToChat(m) {
2181
+ if (m.role === "user") return neutralUserToChat(m);
2182
+ if (m.role === "assistant") return neutralAssistantToChat(m);
2183
+ return {
2184
+ role: "tool",
2185
+ tool_call_id: m.toolCallId,
2186
+ content: m.output
2187
+ };
2188
+ }
2189
+ function neutralToolsToChat(tools) {
2190
+ if (!tools || tools.length === 0) return void 0;
2191
+ return tools.map((t) => ({
2192
+ type: "function",
2193
+ function: {
2194
+ name: t.name,
2195
+ description: t.description,
2196
+ parameters: t.parameters ?? {
2197
+ type: "object",
2198
+ properties: {}
2199
+ }
2200
+ }
2201
+ }));
2202
+ }
2203
+ /**
2204
+ * Convert the parsed (Responses-flat) `tool_choice` to the chat NESTED form.
2205
+ * A forced tool is `{type:"function", function:{name}}` on chat/completions —
2206
+ * distinct from the Responses flat `{type:"function", name}`. `"auto"` /
2207
+ * `"required"` / `"none"` pass through unchanged.
2208
+ */
2209
+ function toolChoiceToChat(tc) {
2210
+ if (tc === void 0) return void 0;
2211
+ if (typeof tc === "string") return tc === "auto" || tc === "none" || tc === "required" ? tc : void 0;
2212
+ if (tc.type === "function" && typeof tc.name === "string" && tc.name.length > 0) return {
2213
+ type: "function",
2214
+ function: { name: tc.name }
2215
+ };
2216
+ }
2217
+ /** Assemble the `/chat/completions` payload from a parsed Anthropic request. */
2218
+ function parsedToChatPayload(parsed) {
2219
+ const messages = [];
2220
+ if (parsed.instructions) messages.push({
2221
+ role: "system",
2222
+ content: parsed.instructions
2223
+ });
2224
+ for (const m of parsed.messages) messages.push(neutralMessageToChat(m));
2225
+ const payload = {
2226
+ model: parsed.model,
2227
+ messages,
2228
+ stream: parsed.stream
2229
+ };
2230
+ const tools = neutralToolsToChat(parsed.tools);
2231
+ if (tools && tools.length > 0) {
2232
+ payload.tools = tools;
2233
+ payload.tool_choice = toolChoiceToChat(parsed.toolChoice) ?? "auto";
2234
+ }
2235
+ if (parsed.reasoningEffort && parsed.reasoningEffort !== "off") payload.reasoning_effort = parsed.reasoningEffort;
2236
+ if (typeof parsed.maxOutputTokens === "number" && parsed.maxOutputTokens > 0) payload.max_tokens = parsed.maxOutputTokens;
2237
+ if (parsed.stopSequences && parsed.stopSequences.length > 0) payload.stop = parsed.stopSequences;
2238
+ if (parsed.parallelToolCalls === false) payload.parallel_tool_calls = false;
2239
+ return payload;
2240
+ }
2241
+ //#endregion
2242
+ //#region src/lib/anthropic-translate/chat-egress.ts
2243
+ /**
2244
+ * Copilot `/chat/completions` → Anthropic Messages egress (the Gemini path).
2245
+ *
2246
+ * - `chatResponseToAnthropicMessage` maps a non-streaming chat/completions
2247
+ * object to an Anthropic Messages object (text + tool_use blocks, usage,
2248
+ * stop_reason).
2249
+ * - `synthAnthropicFromChat` is the streaming state machine: it consumes
2250
+ * Copilot's chat SSE (`choices[].delta`) and yields Anthropic stream events
2251
+ * (message_start → content_block_* → message_delta → message_stop), reusing
2252
+ * the shared `anthropic-sse.ts` frame builders.
2253
+ *
2254
+ * Chat streaming differs from the Responses path in one load-bearing way: there
2255
+ * is NO authoritative per-tool `.done` event carrying the full arguments — a
2256
+ * tool call's `id`/`name` arrive early (first delta for its array `index`) and
2257
+ * its `arguments` stream incrementally across later deltas keyed by that same
2258
+ * array `index`. So each tool is BUFFERED per OpenAI array index and its
2259
+ * Anthropic block is emitted ATOMICALLY at end-of-stream (content_block_start →
2260
+ * one input_json_delta with the full assembled args → content_block_stop). This
2261
+ * carries the Phase 1 C1 correctness lesson to the chat path: parallel/multiple
2262
+ * tools keep DISTINCT block indices and never lose their args to a clobbered
2263
+ * shared pointer. An open TEXT block is closed before any tool block opens (a
2264
+ * tool_use must not nest inside a text block on the wire). The Anthropic block
2265
+ * index is assigned at emit time so indices stay monotonic on the wire.
2266
+ */
2267
+ /** Synthesize a matchable Anthropic tool_use id when the upstream id is absent. */
2268
+ function makeToolUseId$1() {
2269
+ return `toolu_${randomUUID().replace(/-/g, "")}`;
2270
+ }
2271
+ function parseToolArgs$1(raw) {
2272
+ if (typeof raw !== "string" || raw.trim().length === 0) return {};
2273
+ try {
2274
+ const parsed = JSON.parse(raw);
2275
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
2276
+ } catch {}
2277
+ return {};
2278
+ }
2279
+ function anthropicUsageFromChat(u) {
2280
+ if (!u) return {};
2281
+ return {
2282
+ input_tokens: u.prompt_tokens ?? 0,
2283
+ output_tokens: u.completion_tokens ?? 0,
2284
+ cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens ?? 0,
2285
+ cache_creation_input_tokens: 0
2286
+ };
2287
+ }
2288
+ /**
2289
+ * Map a chat/completions `finish_reason` to an Anthropic stop_reason. A
2290
+ * truncated (`length`) response is `max_tokens` even when a partial tool call
2291
+ * is present — the response was cut — mirroring the Responses egress precedence.
2292
+ * `tool_calls` (or any buffered tool) → `tool_use`.
2293
+ *
2294
+ * `content_filter` maps to Anthropic's `refusal`, NOT `end_turn`. It used to
2295
+ * collapse into `end_turn`, which made an upstream safety block indistinguishable
2296
+ * from a normal completion: the client saw a successful, usually empty, message
2297
+ * and had no way to tell that content had been withheld. `refusal` is the
2298
+ * documented Anthropic stop_reason for exactly this case.
2299
+ */
2300
+ function chatStopReason(finishReason, sawTool) {
2301
+ if (finishReason === "length") return "max_tokens";
2302
+ if (finishReason === "tool_calls" || sawTool) return "tool_use";
2303
+ if (finishReason === "content_filter") return "refusal";
2304
+ return "end_turn";
2305
+ }
2306
+ /**
2307
+ * Map a non-streaming chat/completions object to an Anthropic Messages object.
2308
+ * The first choice's `message.content` becomes a text block (when non-empty)
2309
+ * and each `message.tool_calls[]` becomes a tool_use block.
2310
+ */
2311
+ function chatResponseToAnthropicMessage(resp, modelId) {
2312
+ const choice = resp.choices?.[0];
2313
+ const content = [];
2314
+ let sawTool = false;
2315
+ const message = choice?.message;
2316
+ if (message) {
2317
+ if (typeof message.content === "string" && message.content.length > 0) content.push({
2318
+ type: "text",
2319
+ text: message.content
2320
+ });
2321
+ if (typeof message.refusal === "string" && message.refusal.length > 0) content.push({
2322
+ type: "text",
2323
+ text: message.refusal
2324
+ });
2325
+ if (Array.isArray(message.tool_calls)) for (const tc of message.tool_calls) {
2326
+ sawTool = true;
2327
+ const rawId = typeof tc.id === "string" ? tc.id : "";
2328
+ content.push({
2329
+ type: "tool_use",
2330
+ id: rawId.length > 0 ? rawId : makeToolUseId$1(),
2331
+ name: typeof tc.function?.name === "string" ? tc.function.name : "",
2332
+ input: parseToolArgs$1(tc.function?.arguments)
2333
+ });
2334
+ }
2335
+ }
2336
+ const usage = anthropicUsageFromChat(resp.usage);
2337
+ const stopReason = chatStopReason(choice?.finish_reason ?? null, sawTool);
2338
+ return {
2339
+ id: typeof resp.id === "string" && resp.id.length > 0 ? `msg_${resp.id}` : makeMessageId(),
2340
+ type: "message",
2341
+ role: "assistant",
2342
+ model: modelId,
2343
+ content,
2344
+ stop_reason: stopReason,
2345
+ stop_sequence: null,
2346
+ usage: {
2347
+ input_tokens: usage.input_tokens ?? 0,
2348
+ output_tokens: usage.output_tokens ?? 0,
2349
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
2350
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
2351
+ }
2352
+ };
2353
+ }
2354
+ /**
2355
+ * Streaming synthesizer: consume a chat/completions SSE iterable, yield the
2356
+ * Anthropic event sequence. Emits `message_start` first, streams text live,
2357
+ * buffers tool calls per OpenAI array index and flushes them atomically at
2358
+ * end-of-stream (in numeric index order), then a terminal `message_delta`
2359
+ * (accumulated usage + stop_reason) and `message_stop`. The `[DONE]` sentinel
2360
+ * is the authoritative clean-end marker: a stream that ends WITHOUT it is
2361
+ * treated as truncated and throws so the stream adapter can emit a terminal
2362
+ * `event: error`. A clean `[DONE]` carrying no chunk-level `finish_reason`
2363
+ * still completes cleanly (stop_reason from any buffered tool, else `end_turn`).
2364
+ */
2365
+ async function* synthAnthropicFromChat(upstream, opts) {
2366
+ const messageId = opts.messageId ?? makeMessageId();
2367
+ let nextIndex = 0;
2368
+ let activeTextIndex = null;
2369
+ const toolByIndex = /* @__PURE__ */ new Map();
2370
+ let usageIn = 0;
2371
+ let usageOut = 0;
2372
+ let usageCacheRead = 0;
2373
+ let finishReason = null;
2374
+ let sawDone = false;
2375
+ yield makeMessageStart(messageId, opts.modelId);
2376
+ for await (const evt of upstream) {
2377
+ const data = evt?.data;
2378
+ if (data == null) continue;
2379
+ if (data === "[DONE]") {
2380
+ sawDone = true;
2381
+ break;
2382
+ }
2383
+ let chunk;
2384
+ try {
2385
+ chunk = JSON.parse(data);
2386
+ } catch {
2387
+ continue;
2388
+ }
2389
+ if (chunk.usage) {
2390
+ usageIn = Math.max(usageIn, chunk.usage.prompt_tokens ?? 0);
2391
+ usageOut = Math.max(usageOut, chunk.usage.completion_tokens ?? 0);
2392
+ usageCacheRead = Math.max(usageCacheRead, chunk.usage.prompt_tokens_details?.cached_tokens ?? 0);
2393
+ }
2394
+ const choice = chunk.choices?.[0];
2395
+ if (!choice) continue;
2396
+ const delta = choice.delta;
2397
+ if (delta && typeof delta.content === "string" && delta.content.length > 0) {
2398
+ if (activeTextIndex == null) {
2399
+ activeTextIndex = nextIndex++;
2400
+ yield makeContentBlockStart(activeTextIndex, {
2401
+ type: "text",
2402
+ text: ""
2403
+ });
2404
+ }
2405
+ yield makeTextDelta(activeTextIndex, delta.content);
2406
+ }
2407
+ if (delta && typeof delta.refusal === "string" && delta.refusal.length > 0) {
2408
+ if (activeTextIndex == null) {
2409
+ activeTextIndex = nextIndex++;
2410
+ yield makeContentBlockStart(activeTextIndex, {
2411
+ type: "text",
2412
+ text: ""
2413
+ });
2414
+ }
2415
+ yield makeTextDelta(activeTextIndex, delta.refusal);
2416
+ }
2417
+ if (delta && Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) {
2418
+ if (activeTextIndex != null) {
2419
+ yield makeContentBlockStop(activeTextIndex);
2420
+ activeTextIndex = null;
2421
+ }
2422
+ for (const tcd of delta.tool_calls) {
2423
+ if (tcd == null || typeof tcd.index !== "number") continue;
2424
+ let entry = toolByIndex.get(tcd.index);
2425
+ if (!entry) {
2426
+ entry = {
2427
+ id: "",
2428
+ name: "",
2429
+ args: ""
2430
+ };
2431
+ toolByIndex.set(tcd.index, entry);
2432
+ }
2433
+ if (typeof tcd.id === "string" && tcd.id.length > 0) entry.id = tcd.id;
2434
+ const name = tcd.function?.name;
2435
+ if (typeof name === "string" && name.length > 0) entry.name = name;
2436
+ const argDelta = tcd.function?.arguments;
2437
+ if (typeof argDelta === "string" && argDelta.length > 0) entry.args += argDelta;
2438
+ }
2439
+ }
2440
+ if (choice.finish_reason != null) finishReason = choice.finish_reason;
2441
+ }
2442
+ if (!sawDone) throw new Error("chat stream ended without a [DONE] sentinel (truncated)");
2443
+ if (activeTextIndex != null) yield makeContentBlockStop(activeTextIndex);
2444
+ let sawTool = false;
2445
+ const orderedTools = [...toolByIndex.entries()].sort((a, b) => a[0] - b[0]);
2446
+ for (const [, entry] of orderedTools) {
2447
+ sawTool = true;
2448
+ const index = nextIndex++;
2449
+ yield makeContentBlockStart(index, {
2450
+ type: "tool_use",
2451
+ id: entry.id.length > 0 ? entry.id : makeToolUseId$1(),
2452
+ name: entry.name,
2453
+ input: {}
2454
+ });
2455
+ yield makeInputJsonDelta(index, JSON.stringify(parseToolArgs$1(entry.args)));
2456
+ yield makeContentBlockStop(index);
2457
+ }
2458
+ yield makeMessageDelta(chatStopReason(finishReason, sawTool), null, {
2459
+ input_tokens: usageIn,
2460
+ output_tokens: usageOut,
2461
+ cache_read_input_tokens: usageCacheRead,
2462
+ cache_creation_input_tokens: 0
2463
+ });
2464
+ yield makeMessageStop();
2465
+ }
2466
+ //#endregion
2467
+ //#region src/lib/anthropic-translate/responses-egress.ts
2468
+ /**
2469
+ * OpenAI `/responses` → Anthropic Messages egress.
2470
+ *
2471
+ * - `responsesResponseToAnthropicMessage` maps a non-streaming Responses object
2472
+ * to an Anthropic Messages object (content blocks, tool_use, usage,
2473
+ * stop_reason).
2474
+ * - `synthAnthropicFromResponses` is the streaming state machine: it consumes
2475
+ * Copilot's `/responses` SSE and yields Anthropic stream events
2476
+ * (message_start → content_block_* → message_delta → message_stop).
2477
+ *
2478
+ * The decode follows our own empirically-verified `/responses` handling
2479
+ * (`src/lib/worker-agent/stream-fn.ts`): the tool/reasoning block key is the
2480
+ * STABLE `output_index`, never the per-event `item_id` — Copilot re-encrypts
2481
+ * `item_id` on every event, so an id key makes every delta lookup miss and the
2482
+ * tool args drop to `{}`.
2483
+ *
2484
+ * Block lifecycle (mirrors stream-fn.ts): every output item is tracked
2485
+ * independently by its `output_index` key. Parallel/interleaved tool calls
2486
+ * never force-close a sibling — Responses emits ALL `output_item.added` for
2487
+ * parallel tools BEFORE the first `function_call_arguments.delta`, so opening (and
2488
+ * emitting) a tool at `added` time would ship it with empty args and drop the
2489
+ * later deltas. Instead a tool's args are BUFFERED and its Anthropic block is
2490
+ * emitted ATOMICALLY (content_block_start → single input_json_delta with the
2491
+ * full assembled args → content_block_stop) at that tool's OWN
2492
+ * `output_item.done` (or flushed at end-of-stream if the item was left
2493
+ * dangling). Only an open TEXT or THINKING block is closed on a type switch —
2494
+ * never a sibling tool. The Anthropic block index is assigned at emit time, so
2495
+ * indices stay monotonic on the wire regardless of item interleaving.
2496
+ */
2497
+ /**
2498
+ * Stable map key for a `/responses` output item: prefer `output_index`
2499
+ * (constant per item), fall back to the opaque id only when absent. Namespaced
2500
+ * so a numeric index and a string id can never collide.
2501
+ */
2502
+ function responsesKey(outputIndex, fallbackId) {
2503
+ if (typeof outputIndex === "number") return `oi:${outputIndex}`;
2504
+ if (typeof fallbackId === "string" && fallbackId.length > 0) return `id:${fallbackId}`;
2505
+ }
2506
+ /** Synthesize a matchable Anthropic tool_use id when the upstream id is absent. */
2507
+ function makeToolUseId() {
2508
+ return `toolu_${randomUUID().replace(/-/g, "")}`;
2509
+ }
2510
+ /** First non-empty string among the candidates, or "" when none qualifies. */
2511
+ function firstNonEmpty(...vals) {
2512
+ for (const v of vals) if (typeof v === "string" && v.length > 0) return v;
2513
+ return "";
2514
+ }
2515
+ function anthropicUsageFromResponses(u) {
2516
+ if (!u) return {};
2517
+ return {
2518
+ input_tokens: u.input_tokens ?? 0,
2519
+ output_tokens: u.output_tokens ?? 0,
2520
+ cache_read_input_tokens: u.input_tokens_details?.cached_tokens ?? 0,
2521
+ cache_creation_input_tokens: 0
2522
+ };
2523
+ }
2524
+ function parseToolArgs(raw) {
2525
+ if (typeof raw !== "string" || raw.trim().length === 0) return {};
2526
+ try {
2527
+ const parsed = JSON.parse(raw);
2528
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
2529
+ } catch {}
2530
+ return {};
2531
+ }
2532
+ /**
2533
+ * Map a non-streaming Responses object to an Anthropic Messages object.
2534
+ *
2535
+ * stop_reason precedence for a completed non-streaming response:
2536
+ * an incomplete/max-output response is `max_tokens` even if a partial tool call
2537
+ * is present (the response was truncated), else a function_call → `tool_use`,
2538
+ * else `end_turn`.
2539
+ */
2540
+ function responsesResponseToAnthropicMessage(resp, modelId) {
2541
+ const output = Array.isArray(resp.output) ? resp.output : [];
2542
+ const content = [];
2543
+ let sawToolUse = false;
2544
+ for (const rawItem of output) {
2545
+ if (!rawItem || typeof rawItem !== "object") continue;
2546
+ const item = rawItem;
2547
+ if (item.type === "message") {
2548
+ let text = "";
2549
+ if (Array.isArray(item.content)) {
2550
+ for (const part of item.content) if (part && typeof part === "object" && part.type === "output_text" && typeof part.text === "string") text += part.text;
2551
+ }
2552
+ if (text.length > 0) content.push({
2553
+ type: "text",
2554
+ text
2555
+ });
2556
+ } else if (item.type === "function_call") {
2557
+ sawToolUse = true;
2558
+ const rawId = typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : typeof item.id === "string" && item.id.length > 0 ? item.id : "";
2559
+ content.push({
2560
+ type: "tool_use",
2561
+ id: rawId.length > 0 ? rawId : makeToolUseId(),
2562
+ name: typeof item.name === "string" ? item.name : "",
2563
+ input: parseToolArgs(item.arguments)
2564
+ });
2565
+ } else if (item.type === "reasoning") {
2566
+ let thinking = "";
2567
+ if (Array.isArray(item.summary)) {
2568
+ for (const part of item.summary) if (part && typeof part === "object" && typeof part.text === "string") thinking += part.text;
2569
+ }
2570
+ if (thinking.length > 0) content.push({
2571
+ type: "thinking",
2572
+ thinking,
2573
+ signature: typeof item.encrypted_content === "string" ? item.encrypted_content : ""
2574
+ });
2575
+ }
2576
+ }
2577
+ const usage = anthropicUsageFromResponses(resp.usage);
2578
+ const stopReason = resp.status === "incomplete" && resp.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : sawToolUse ? "tool_use" : "end_turn";
2579
+ return {
2580
+ id: typeof resp.id === "string" && resp.id.length > 0 ? `msg_${resp.id}` : makeMessageId(),
2581
+ type: "message",
2582
+ role: "assistant",
2583
+ model: modelId,
2584
+ content,
2585
+ stop_reason: stopReason,
2586
+ stop_sequence: null,
2587
+ usage: {
2588
+ input_tokens: usage.input_tokens ?? 0,
2589
+ output_tokens: usage.output_tokens ?? 0,
2590
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
2591
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
2592
+ }
2593
+ };
2594
+ }
2595
+ /**
2596
+ * Streaming synthesizer: consume a `/responses` SSE iterable, yield the
2597
+ * Anthropic event sequence. Emits `message_start` first, then content blocks in
2598
+ * item order, then a terminal `message_delta` (accumulated usage + stop_reason)
2599
+ * and `message_stop`. A `response.failed` throws so the stream adapter can emit
2600
+ * a terminal `event: error`; a stream that ends WITHOUT a terminal event
2601
+ * (`completed`/`incomplete`/`failed`) is treated as truncated and throws too.
2602
+ */
2603
+ async function* synthAnthropicFromResponses(upstream, opts) {
2604
+ const messageId = opts.messageId ?? makeMessageId();
2605
+ const q = [];
2606
+ let nextIndex = 0;
2607
+ let current = null;
2608
+ const toolByKey = /* @__PURE__ */ new Map();
2609
+ const thinkingByKey = /* @__PURE__ */ new Map();
2610
+ const textByKey = /* @__PURE__ */ new Map();
2611
+ let usageIn = 0;
2612
+ let usageOut = 0;
2613
+ let usageCacheRead = 0;
2614
+ let sawTool = false;
2615
+ let hitMaxTokens = false;
2616
+ let sawTerminal = false;
2617
+ const closeCurrent = () => {
2618
+ if (!current) return;
2619
+ q.push(makeContentBlockStop(current.index));
2620
+ current = null;
2621
+ };
2622
+ const currentKind = () => current ? current.kind : null;
2623
+ const currentIndex = () => current ? current.index : null;
2624
+ const ensureTextState = (key) => {
2625
+ const existing = textByKey.get(key);
2626
+ if (existing != null && current?.kind === "text" && current.index === existing.index) return existing;
2627
+ closeCurrent();
2628
+ const index = nextIndex++;
2629
+ const state = {
2630
+ index,
2631
+ emitted: ""
2632
+ };
2633
+ textByKey.set(key, state);
2634
+ current = {
2635
+ index,
2636
+ kind: "text"
2637
+ };
2638
+ q.push(makeContentBlockStart(index, {
2639
+ type: "text",
2640
+ text: ""
2641
+ }));
2642
+ return state;
2643
+ };
2644
+ const ensureThinking = (key) => {
2645
+ if (current?.kind === "thinking" && thinkingByKey.get(key) === current.index) return current.index;
2646
+ closeCurrent();
2647
+ const index = nextIndex++;
2648
+ current = {
2649
+ index,
2650
+ kind: "thinking"
2651
+ };
2652
+ thinkingByKey.set(key, index);
2653
+ q.push(makeContentBlockStart(index, {
2654
+ type: "thinking",
2655
+ thinking: ""
2656
+ }));
2657
+ return index;
2658
+ };
2659
+ const emitTool = (t) => {
2660
+ if (t.emitted) return;
2661
+ closeCurrent();
2662
+ const index = nextIndex++;
2663
+ q.push(makeContentBlockStart(index, {
2664
+ type: "tool_use",
2665
+ id: t.id,
2666
+ name: t.name,
2667
+ input: {}
2668
+ }));
2669
+ const args = t.argsBuffer.length > 0 ? t.argsBuffer : "{}";
2670
+ q.push(makeInputJsonDelta(index, args));
2671
+ q.push(makeContentBlockStop(index));
2672
+ t.emitted = true;
2673
+ sawTool = true;
2674
+ };
2675
+ q.push(makeMessageStart(messageId, opts.modelId));
2676
+ for (const e of q) yield e;
2677
+ q.length = 0;
2678
+ for await (const evt of upstream) {
2679
+ const data = evt?.data;
2680
+ if (data == null) continue;
2681
+ if (data === "[DONE]") break;
2682
+ let ev;
2683
+ try {
2684
+ ev = JSON.parse(data);
2685
+ } catch {
2686
+ continue;
2687
+ }
2688
+ switch (ev.type) {
2689
+ case "response.output_text.delta": {
2690
+ const d = ev.delta;
2691
+ if (typeof d !== "string" || d.length === 0) break;
2692
+ const state = ensureTextState(responsesKey(ev.output_index, ev.item_id) ?? "text");
2693
+ state.emitted += d;
2694
+ q.push(makeTextDelta(state.index, d));
2695
+ break;
2696
+ }
2697
+ case "response.output_text.done": {
2698
+ const key = responsesKey(ev.output_index, ev.item_id) ?? "text";
2699
+ const fullText = typeof ev.text === "string" ? ev.text : "";
2700
+ const existing = textByKey.get(key);
2701
+ if (existing == null) {
2702
+ if (fullText.length > 0) {
2703
+ const state = ensureTextState(key);
2704
+ state.emitted = fullText;
2705
+ q.push(makeTextDelta(state.index, fullText));
2706
+ closeCurrent();
2707
+ }
2708
+ } else if (currentKind() === "text" && currentIndex() === existing.index) {
2709
+ if (fullText.length > existing.emitted.length && fullText.startsWith(existing.emitted)) {
2710
+ q.push(makeTextDelta(existing.index, fullText.slice(existing.emitted.length)));
2711
+ existing.emitted = fullText;
2712
+ }
2713
+ closeCurrent();
2714
+ }
2715
+ break;
2716
+ }
2717
+ case "response.reasoning_summary_text.delta":
2718
+ case "response.reasoning_text.delta": {
2719
+ const d = ev.delta;
2720
+ if (typeof d !== "string" || d.length === 0) break;
2721
+ const key = responsesKey(ev.output_index, ev.item_id) ?? "reasoning";
2722
+ q.push(makeThinkingDelta(ensureThinking(key), d));
2723
+ break;
2724
+ }
2725
+ case "response.reasoning_summary_text.done":
2726
+ case "response.reasoning_text.done": break;
2727
+ case "response.output_item.added": {
2728
+ const item = ev.item;
2729
+ if (item?.type === "function_call") {
2730
+ const key = responsesKey(ev.output_index, item.id);
2731
+ if (key == null || toolByKey.has(key)) break;
2732
+ const toolId = firstNonEmpty(item.call_id, item.id);
2733
+ toolByKey.set(key, {
2734
+ id: toolId.length > 0 ? toolId : makeToolUseId(),
2735
+ name: item.name ?? "",
2736
+ argsBuffer: "",
2737
+ emitted: false
2738
+ });
2739
+ sawTool = true;
2740
+ }
2741
+ break;
2742
+ }
2743
+ case "response.function_call_arguments.delta": {
2744
+ const key = responsesKey(ev.output_index, ev.item_id);
2745
+ if (key == null) break;
2746
+ const t = toolByKey.get(key);
2747
+ if (!t || t.emitted) break;
2748
+ const d = ev.delta;
2749
+ if (typeof d !== "string" || d.length === 0) break;
2750
+ t.argsBuffer += d;
2751
+ break;
2752
+ }
2753
+ case "response.function_call_arguments.done": {
2754
+ const key = responsesKey(ev.output_index, ev.item_id);
2755
+ if (key == null) break;
2756
+ const t = toolByKey.get(key);
2757
+ if (!t || t.emitted) break;
2758
+ if (typeof ev.arguments === "string") t.argsBuffer = ev.arguments;
2759
+ break;
2760
+ }
2761
+ case "response.output_item.done": {
2762
+ const item = ev.item;
2763
+ if (item?.type === "function_call") {
2764
+ const key = responsesKey(ev.output_index, item.id);
2765
+ if (key == null) break;
2766
+ const t = toolByKey.get(key);
2767
+ if (!t || t.emitted) break;
2768
+ const doneId = firstNonEmpty(item.call_id, item.id);
2769
+ if (doneId.length > 0) t.id = doneId;
2770
+ if (typeof item.name === "string" && item.name.length > 0) t.name = item.name;
2771
+ if (typeof item.arguments === "string") t.argsBuffer = item.arguments;
2772
+ emitTool(t);
2773
+ } else if (item?.type === "reasoning") {
2774
+ const key = responsesKey(ev.output_index, item.id);
2775
+ const idx = key != null ? thinkingByKey.get(key) : void 0;
2776
+ if (idx != null && currentKind() === "thinking" && currentIndex() === idx) {
2777
+ if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) q.push(makeSignatureDelta(idx, item.encrypted_content));
2778
+ closeCurrent();
2779
+ }
2780
+ }
2781
+ break;
2782
+ }
2783
+ case "response.completed":
2784
+ case "response.incomplete": {
2785
+ sawTerminal = true;
2786
+ const u = ev.response?.usage;
2787
+ if (u) {
2788
+ usageIn = Math.max(usageIn, u.input_tokens ?? 0);
2789
+ usageOut = Math.max(usageOut, u.output_tokens ?? 0);
2790
+ usageCacheRead = Math.max(usageCacheRead, u.input_tokens_details?.cached_tokens ?? 0);
2791
+ }
2792
+ if (ev.type === "response.incomplete" && ev.response?.incomplete_details?.reason === "max_output_tokens") hitMaxTokens = true;
2793
+ break;
2794
+ }
2795
+ case "response.failed": throw new Error(ev.response?.error?.message ?? "response.failed");
2796
+ }
2797
+ for (const e of q) yield e;
2798
+ q.length = 0;
2799
+ }
2800
+ if (!sawTerminal) throw new Error("responses stream ended without a terminal event (truncated)");
2801
+ closeCurrent();
2802
+ for (const t of toolByKey.values()) if (!t.emitted) emitTool(t);
2803
+ const stopReason = hitMaxTokens ? "max_tokens" : sawTool ? "tool_use" : "end_turn";
2804
+ q.push(makeMessageDelta(stopReason, null, {
2805
+ input_tokens: usageIn,
2806
+ output_tokens: usageOut,
2807
+ cache_read_input_tokens: usageCacheRead,
2808
+ cache_creation_input_tokens: 0
2809
+ }));
2810
+ q.push(makeMessageStop());
2811
+ for (const e of q) yield e;
2812
+ q.length = 0;
2813
+ }
2814
+ //#endregion
2815
+ //#region src/lib/anthropic-translate/classifier.ts
2816
+ /**
2817
+ * Routing classifier for `POST /v1/messages`.
2818
+ *
2819
+ * Claude Code speaks the Anthropic Messages wire format. Copilot only serves
2820
+ * Claude models on its native `/v1/messages` endpoint; a gpt/gemini request
2821
+ * sent there 400s. This classifier decides, from the RESOLVED model id and its
2822
+ * catalog metadata, whether a request stays on the native passthrough
2823
+ * (`createMessages`) or is diverted to the translation shim.
2824
+ *
2825
+ * Non-regression is the whole point — every Claude model (opus / sonnet / haiku /
2826
+ * any `claude-*` or Anthropic-vendored id) MUST return "claude-passthrough" so
2827
+ * its bytes reach `createMessages` unchanged — even if future catalog metadata
2828
+ * were to (wrongly) advertise a `/responses` endpoint for it. The Claude check
2829
+ * is therefore keyed off identity (id / vendor / family), NOT the endpoint.
2830
+ *
2831
+ * Non-Claude models are diverted to the translation shim by the endpoint the
2832
+ * catalog says they serve: `/responses` models (gpt-5.5, gpt-5.3-codex, …) take
2833
+ * the Responses path (`responses-shim`), `/chat/completions` models (gemini,
2834
+ * and any chat-default model) take the chat path (`chat-shim`). The decision is
2835
+ * derived from `pickEndpoint` (catalog `supported_endpoints`), never a
2836
+ * hardcoded slug list, so it generalizes. Copilot only serves Claude models on
2837
+ * its native `/v1/messages`, so diverting every non-Claude model to a shim is
2838
+ * correct — a non-Claude request sent to `/v1/messages` would 400.
2839
+ */
2840
+ /**
2841
+ * Match "claude"/"anthropic" as a delimiter-bounded segment anywhere in a model
2842
+ * id: at the start, or after a `/ _ . : -` path/version delimiter, and followed
2843
+ * by end-of-string, another such delimiter, or a digit. This catches catalog
2844
+ * aliases like `github/claude-3-7-sonnet` or `anthropic/…` whose vendor is
2845
+ * "github" and whose family is empty — where the token only surfaces mid-id —
2846
+ * while NOT firing on incidental substrings like `notclaude`. Deliberately
2847
+ * over-inclusive at the boundaries: we fail CLOSED toward Claude (→ passthrough)
2848
+ * so a real Claude model can never be diverted to the non-Claude shim.
2849
+ */
2850
+ const CLAUDE_ID_RE = /(^|[/_.:-])(claude|anthropic)(?=$|[/_.:-]|\d)/i;
2851
+ /**
2852
+ * True when the target is a Claude / Anthropic model. Matches on any of:
2853
+ * catalog vendor containing "anthropic", capability family containing "claude",
2854
+ * or a Claude/anthropic path-segment (see `CLAUDE_ID_RE`) in ANY id we hold —
2855
+ * the resolved id (`modelId`), the pre-resolution request id (`originalModelId`),
2856
+ * or the catalog entry's own id (`model.id`). Conservative by design: when in
2857
+ * doubt it returns true so a Claude request can never be diverted to the shim.
2858
+ */
2859
+ function isClaudeModel(modelId, model, originalModelId) {
2860
+ if (model) {
2861
+ if ((model.vendor?.toLowerCase() ?? "").includes("anthropic")) return true;
2862
+ if ((model.capabilities?.family?.toLowerCase() ?? "").includes("claude")) return true;
2863
+ }
2864
+ return [
2865
+ modelId,
2866
+ originalModelId,
2867
+ model?.id
2868
+ ].some((id) => typeof id === "string" && CLAUDE_ID_RE.test(id));
2869
+ }
2870
+ /**
2871
+ * Decide the route for a resolved model id + its catalog entry.
2872
+ *
2873
+ * - No model id, or a Claude model → "claude-passthrough" (existing behaviour).
2874
+ * - A non-Claude model whose catalog endpoint is `/responses` → "responses-shim".
2875
+ * - A non-Claude model whose catalog endpoint is `/chat/completions` (gemini and
2876
+ * any chat-default model) → "chat-shim".
2877
+ * - A non-Claude model absent from the catalog (so we can't confirm an endpoint)
2878
+ * → "claude-passthrough" (unchanged; we don't divert what we can't classify).
2879
+ * - A non-Claude model that IS in the catalog and serves NEITHER of the two shim
2880
+ * endpoints (`pickEndpoint` → undefined) → "claude-passthrough" as well.
2881
+ *
2882
+ * Those last two land on the same route but are NOT the same answer, and the
2883
+ * coincidence is deliberate rather than a collapsed default (contrast
2884
+ * `resolveEndpointForModelId`, whose callers must tell them apart because
2885
+ * guessing there produces an opaque upstream 400). Here neither shim is even a
2886
+ * candidate: a shim can only speak `/responses` or `/chat/completions`, so
2887
+ * diverting a model that serves neither would 400 just as surely. Passthrough
2888
+ * is the better default because it is sometimes RIGHT — a non-Claude catalog
2889
+ * model advertising `/v1/messages` is served by exactly the endpoint
2890
+ * passthrough uses. It also preserves this module's fail-CLOSED-toward-Claude
2891
+ * invariant: an unclassifiable model is never diverted.
2892
+ *
2893
+ * KNOWN GAP (audited, deliberately not fixed here): a non-Claude model serving
2894
+ * only something we cannot speak at all (say `/embeddings`) also lands on
2895
+ * passthrough and will still 400 upstream — `logEndpointMismatch(modelId,
2896
+ * "/v1/messages")` logs it at the passthrough seam, but no local error is
2897
+ * raised. Closing that needs a change in `src/routes/messages/handler.ts`,
2898
+ * which this seam does not own. It is strictly narrower than the defect fixed
2899
+ * in `resolveEndpointForModelId`: no such model is reachable as a Claude Code
2900
+ * `/v1/messages` target today, whereas the plan worker's `claude-opus-5`
2901
+ * default is.
2902
+ *
2903
+ * `originalModelId` is the optional pre-resolution request id; when supplied it
2904
+ * is checked for Claude-likeness alongside the resolved id so an alias that
2905
+ * resolves to a non-Claude-looking id can't slip past.
2906
+ */
2907
+ function classifyMessagesRoute(modelId, model, originalModelId) {
2908
+ if (!modelId) return "claude-passthrough";
2909
+ if (isClaudeModel(modelId, model, originalModelId)) return "claude-passthrough";
2910
+ if (!model) return "claude-passthrough";
2911
+ const endpoint = pickEndpoint(model);
2912
+ if (endpoint === "responses") return "responses-shim";
2913
+ if (endpoint === "chat") return "chat-shim";
2914
+ return "claude-passthrough";
2915
+ }
2916
+ //#endregion
2917
+ //#region src/lib/anthropic-translate/index.ts
2918
+ const STREAM_HEADERS = {
2919
+ "content-type": "text/event-stream",
2920
+ "cache-control": "no-cache",
2921
+ "transfer-encoding": "chunked",
2922
+ connection: "keep-alive"
2923
+ };
2924
+ function isAsyncIterable(x) {
2925
+ return x != null && typeof x[Symbol.asyncIterator] === "function";
2926
+ }
2927
+ /**
2928
+ * Handle a `/v1/messages` request targeting a non-Claude `/responses` model.
2929
+ * Returns a streaming or non-streaming Anthropic-format Response. Upstream
2930
+ * non-2xx / abort errors are thrown (as HTTPError) and handled by the route's
2931
+ * `forwardError`, exactly like the passthrough path.
2932
+ */
2933
+ async function handleNonClaudeResponses(c, opts) {
2934
+ const routePath = c.req.path;
2935
+ let body;
2936
+ try {
2937
+ body = JSON.parse(opts.rawBody);
2938
+ } catch {
2939
+ return c.json({
2940
+ type: "error",
2941
+ error: {
2942
+ type: "invalid_request_error",
2943
+ message: "Request body is not valid JSON"
2944
+ }
2945
+ }, 400);
2946
+ }
2947
+ const parsed = parseAnthropicRequest(body, opts.modelId, opts.model);
2948
+ const payload = parsedToResponsesPayload(parsed);
2949
+ if (consola.level >= 4) consola.debug(`Anthropic-translate → /responses model=${opts.modelId} stream=${parsed.stream} tools=${parsed.tools?.length ?? 0} effort=${parsed.reasoningEffort ?? "none"}`);
2950
+ if (parsed.stream) {
2951
+ const aborter = new AbortController();
2952
+ const result = await createResponses(payload, opts.model?.requestHeaders, aborter.signal, true);
2953
+ if (!isAsyncIterable(result)) throw new Error("Upstream /responses did not return an SSE stream (stream: true expected)");
2954
+ logRequest({
2955
+ method: "POST",
2956
+ path: routePath,
2957
+ model: opts.originalModel,
2958
+ resolvedModel: opts.modelId,
2959
+ status: 200,
2960
+ streaming: true
2961
+ }, opts.model, opts.startTime);
2962
+ const stream = anthropicSseStreamFromEvents(synthAnthropicFromResponses(result, { modelId: opts.modelId }), {
2963
+ routePath,
2964
+ onCancel: () => aborter.abort()
2965
+ });
2966
+ return new Response(stream, {
2967
+ status: 200,
2968
+ headers: STREAM_HEADERS
2969
+ });
2970
+ }
2971
+ const anthropic = responsesResponseToAnthropicMessage(await createResponses(payload, opts.model?.requestHeaders, void 0, true), opts.modelId);
2972
+ logRequest({
2973
+ method: "POST",
2974
+ path: routePath,
2975
+ model: opts.originalModel,
2976
+ resolvedModel: opts.modelId,
2977
+ inputTokens: anthropic.usage.input_tokens,
2978
+ outputTokens: anthropic.usage.output_tokens,
2979
+ status: 200
2980
+ }, opts.model, opts.startTime);
2981
+ return c.json(anthropic, 200);
2982
+ }
2983
+ /**
2984
+ * Handle a `/v1/messages` request targeting a non-Claude `/chat/completions`
2985
+ * model (gemini). Twin of `handleNonClaudeResponses` — same lifecycle, abort,
2986
+ * and logging contract, but assembles a chat/completions payload and translates
2987
+ * the chat response (object or SSE) back to the Anthropic wire shape.
2988
+ */
2989
+ async function handleNonClaudeChat(c, opts) {
2990
+ const routePath = c.req.path;
2991
+ let body;
2992
+ try {
2993
+ body = JSON.parse(opts.rawBody);
2994
+ } catch {
2995
+ return c.json({
2996
+ type: "error",
2997
+ error: {
2998
+ type: "invalid_request_error",
2999
+ message: "Request body is not valid JSON"
3000
+ }
3001
+ }, 400);
3002
+ }
3003
+ const parsed = parseAnthropicRequest(body, opts.modelId, opts.model);
3004
+ const payload = parsedToChatPayload(parsed);
3005
+ if (consola.level >= 4) consola.debug(`Anthropic-translate → /chat/completions model=${opts.modelId} stream=${parsed.stream} tools=${parsed.tools?.length ?? 0} effort=${parsed.reasoningEffort ?? "none"}`);
3006
+ if (parsed.stream) {
3007
+ const aborter = new AbortController();
3008
+ const result = await createChatCompletions(payload, opts.model?.requestHeaders, aborter.signal, true);
3009
+ if (!isAsyncIterable(result)) throw new Error("Upstream /chat/completions did not return an SSE stream (stream: true expected)");
3010
+ logRequest({
3011
+ method: "POST",
3012
+ path: routePath,
3013
+ model: opts.originalModel,
3014
+ resolvedModel: opts.modelId,
3015
+ status: 200,
3016
+ streaming: true
3017
+ }, opts.model, opts.startTime);
3018
+ const stream = anthropicSseStreamFromEvents(synthAnthropicFromChat(result, { modelId: opts.modelId }), {
3019
+ routePath,
3020
+ onCancel: () => aborter.abort()
3021
+ });
3022
+ return new Response(stream, {
3023
+ status: 200,
3024
+ headers: STREAM_HEADERS
3025
+ });
3026
+ }
3027
+ const anthropic = chatResponseToAnthropicMessage(await createChatCompletions(payload, opts.model?.requestHeaders, void 0, true), opts.modelId);
3028
+ logRequest({
3029
+ method: "POST",
3030
+ path: routePath,
3031
+ model: opts.originalModel,
3032
+ resolvedModel: opts.modelId,
3033
+ inputTokens: anthropic.usage.input_tokens,
3034
+ outputTokens: anthropic.usage.output_tokens,
3035
+ status: 200
3036
+ }, opts.model, opts.startTime);
3037
+ return c.json(anthropic, 200);
3038
+ }
3039
+ //#endregion
3040
+ //#region src/routes/messages/handler.ts
3041
+ const isWebSearchTool$1 = (tool) => !!tool && typeof tool === "object" && (typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search");
3042
+ /**
3043
+ * Extract whitelisted beta headers from the incoming request to forward
3044
+ * to the Copilot API. VS Code sends these to enable extended features
3045
+ * like thinking, context management, and advanced tool use.
3046
+ */
3047
+ function extractBetaHeaders(c) {
3048
+ const headers = {};
3049
+ const anthropicBeta = c.req.header("anthropic-beta");
3050
+ if (anthropicBeta) {
3051
+ const filtered = filterBetaHeader(anthropicBeta);
3052
+ if (filtered) headers["anthropic-beta"] = filtered;
3053
+ }
3054
+ return headers;
3055
+ }
3056
+ /**
3057
+ * Extract the text content from the last user message for web search.
3058
+ * Handles both string content and content block arrays (multimodal).
3059
+ */
3060
+ function extractUserQuery$1(messages) {
3061
+ for (let i = messages.length - 1; i >= 0; i--) {
3062
+ const msg = messages[i];
3063
+ if (msg.role === "user") {
3064
+ if (typeof msg.content === "string") return msg.content;
3065
+ if (Array.isArray(msg.content)) {
3066
+ const textBlock = msg.content.find((block) => block.type === "text");
3067
+ if (textBlock?.text) return textBlock.text;
3068
+ }
3069
+ }
3070
+ }
3071
+ }
3072
+ /**
3073
+ * Check if any user message contains tool_result content blocks,
3074
+ * indicating a follow-up turn where we should skip web search.
3075
+ * In Anthropic format, tool results are content blocks inside user messages,
3076
+ * NOT separate role: "tool" messages like in OpenAI format.
3077
+ */
3078
+ function hasToolResultContent(messages) {
3079
+ return messages.some((msg) => Array.isArray(msg.content) && msg.content.some((block) => block.type === "tool_result"));
3080
+ }
3081
+ /**
3082
+ * Inject web search results into the Anthropic system field.
3083
+ * Handles three cases: absent, string, or array of content blocks.
3084
+ * When array, prepends without cache_control to preserve existing directives.
3085
+ */
3086
+ function injectSearchResults(body, searchContext) {
3087
+ if (body.system === void 0 || body.system === null) body.system = searchContext;
3088
+ else if (typeof body.system === "string") body.system = `${searchContext}\n\n${body.system}`;
3089
+ else if (Array.isArray(body.system)) body.system = [{
3090
+ type: "text",
3091
+ text: searchContext
3092
+ }, ...body.system];
3093
+ }
3094
+ /**
3095
+ * Strip web_search tools from the request and clean up tool_choice.
3096
+ * Returns the modified body object.
3097
+ */
3098
+ function stripWebSearchTool(body) {
3099
+ if (!body.tools) return;
3100
+ const tools = body.tools.filter((tool) => !isWebSearchTool$1(tool));
3101
+ body.tools = tools;
3102
+ if (tools.length === 0) {
3103
+ body.tools = void 0;
3104
+ body.tool_choice = void 0;
3105
+ } else if (body.tool_choice && typeof body.tool_choice === "object" && body.tool_choice.type === "tool") {
3106
+ const choiceName = body.tool_choice.name;
3107
+ if (choiceName && !tools.some((tool) => tool && typeof tool === "object" && tool.name === choiceName)) body.tool_choice = { type: "auto" };
3108
+ }
3109
+ }
3110
+ /**
3111
+ * Strip the injected `__anthropic_advisor` tool (and any Anthropic-native
3112
+ * `advisor_*` typed tool) from a request body. Used ONLY on the non-Claude
3113
+ * shim path: ADVISOR's server-side translate-loop (buildAdvisorStream) lives
3114
+ * on the native /v1/messages route, so a non-Claude model has no handler for
3115
+ * the tool and it must be removed before forwarding — otherwise the model
3116
+ * could emit a tool_use that nothing fulfils. Mirrors stripWebSearchTool's
3117
+ * tool_choice cleanup. Returns the original string (same reference) when
3118
+ * nothing was removed.
3119
+ */
3120
+ function stripAdvisorTool(rawBody) {
3121
+ let body;
3122
+ try {
3123
+ body = JSON.parse(rawBody);
3124
+ } catch {
3125
+ return rawBody;
3126
+ }
3127
+ if (!Array.isArray(body.tools)) return rawBody;
3128
+ const original = body.tools;
3129
+ const tools = original.filter((tool) => {
3130
+ if (typeof tool !== "object" || tool === null) return true;
3131
+ if (tool.name === "__anthropic_advisor") return false;
3132
+ const type = tool.type;
3133
+ return typeof type !== "string" || !type.startsWith("advisor_");
3134
+ });
3135
+ if (tools.length === original.length) return rawBody;
3136
+ if (tools.length === 0) {
3137
+ body.tools = void 0;
3138
+ body.tool_choice = void 0;
3139
+ } else {
3140
+ body.tools = tools;
3141
+ if (body.tool_choice && typeof body.tool_choice === "object" && body.tool_choice.type === "tool") {
3142
+ const choiceName = body.tool_choice.name;
3143
+ if (choiceName && !tools.some((tool) => tool && typeof tool === "object" && tool.name === choiceName)) body.tool_choice = { type: "auto" };
3144
+ }
3145
+ }
3146
+ return JSON.stringify(body);
3147
+ }
3148
+ /**
3149
+ * Process web search if the request contains a web_search tool.
3150
+ * Performs the search, injects results into system, and strips the tool.
3151
+ * Returns the (possibly modified) body string to forward.
3152
+ */
3153
+ async function processWebSearch(rawBody) {
3154
+ if (!rawBody.includes("web_search")) return rawBody;
3155
+ let body;
3156
+ try {
3157
+ body = JSON.parse(rawBody);
3158
+ } catch {
3159
+ return rawBody;
3160
+ }
3161
+ if (!body.tools?.some((tool) => isWebSearchTool$1(tool))) return rawBody;
3162
+ const messages = body.messages ?? [];
3163
+ const query = hasToolResultContent(messages) ? void 0 : extractUserQuery$1(messages);
3164
+ if (query) try {
3165
+ const results = await searchWeb(query);
3166
+ const searchContext = [
3167
+ "[Web Search Results]",
3168
+ results.content,
3169
+ "",
3170
+ results.references.map((r) => `- [${r.title}](${r.url})`).join("\n"),
3171
+ "[End Web Search Results]"
3172
+ ].join("\n");
3173
+ injectSearchResults(body, searchContext);
3174
+ } catch (error) {
3175
+ consola.warn("Web search failed, continuing without results:", error);
3176
+ }
3177
+ stripWebSearchTool(body);
3178
+ return JSON.stringify(body);
3179
+ }
3180
+ async function handleCompletion(c) {
3181
+ const startTime = Date.now();
3182
+ await checkRateLimit(state);
3183
+ const rawBody = await c.req.text();
3184
+ const debugEnabled = consola.level >= 4;
3185
+ if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
3186
+ if (process.env.GH_ROUTER_LOG_FIELDS === "1") {
3187
+ let parsedForLog = void 0;
3188
+ try {
3189
+ parsedForLog = JSON.parse(rawBody);
3190
+ } catch {}
3191
+ logRequestFields({
3192
+ path: c.req.path,
3193
+ body: parsedForLog,
3194
+ betaHeader: c.req.header("anthropic-beta"),
3195
+ agentId: c.req.header("x-claude-code-agent-id")
3196
+ });
3197
+ }
3198
+ if (state.manualApprove) await awaitApproval();
3199
+ const betaHeaders = extractBetaHeaders(c);
3200
+ const incomingBeta = c.req.header("anthropic-beta");
3201
+ const advisorEnabled = isAdvisorRequested(incomingBeta);
3202
+ let finalBody = await processWebSearch(rawBody);
3203
+ finalBody = sanitizeAnthropicBody(finalBody);
3204
+ if (advisorEnabled) {
3205
+ finalBody = injectAdvisorTool(finalBody);
3206
+ consola.info("ADVISOR enabled for this request — injecting __anthropic_advisor tool; will translate tool_use → server_tool_use{advisor} on the SSE stream");
3207
+ }
3208
+ if (finalBody.includes("\"mcp_servers\"")) try {
3209
+ const probe = JSON.parse(finalBody);
3210
+ if (Array.isArray(probe.mcp_servers) && probe.mcp_servers.length > 0) return c.json({
3211
+ type: "error",
3212
+ error: {
3213
+ type: "invalid_request_error",
3214
+ message: "Inline `mcp_servers` body field is not supported by github-router. Configure remote MCP servers as local stdio entries in `~/.claude/mcp.json` instead — Claude Code will spawn them locally and the proxy passes their tool calls through transparently. (https://docs.claude.com/en/docs/claude-code/mcp)"
3215
+ }
3216
+ }, 400);
3217
+ } catch {}
3218
+ const { body: resolvedBody, originalModel, resolvedModel, selectedModel } = resolveModelInBody$1(finalBody);
3219
+ const modelId = resolvedModel ?? originalModel;
3220
+ const messagesRoute = classifyMessagesRoute(modelId, selectedModel, originalModel);
3221
+ if (messagesRoute !== "claude-passthrough") {
3222
+ const shimBody = stripAdvisorTool(resolvedBody);
3223
+ if (advisorEnabled) consola.info("ADVISOR requested with a non-Claude model — stripping the injected __anthropic_advisor tool and proceeding without advisor (Claude-only feature; gracefully degraded).");
3224
+ const shimOpts = {
3225
+ rawBody: shimBody,
3226
+ modelId,
3227
+ model: selectedModel,
3228
+ originalModel,
3229
+ startTime
3230
+ };
3231
+ return messagesRoute === "chat-shim" ? handleNonClaudeChat(c, shimOpts) : handleNonClaudeResponses(c, shimOpts);
3232
+ }
3233
+ if (modelId) logEndpointMismatch(modelId, "/v1/messages");
3234
+ const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
3235
+ const advisorAborter = advisorEnabled ? new AbortController() : void 0;
3236
+ let response;
3237
+ try {
3238
+ response = await createMessages(resolvedBody, {
3239
+ ...selectedModel?.requestHeaders,
3240
+ ...effectiveBetas
3241
+ }, advisorAborter?.signal, true);
3242
+ } catch (error) {
3243
+ if (error instanceof HTTPError) {
3244
+ const errorBody = await error.response.clone().text().catch(() => "");
3245
+ logRequest({
3246
+ method: "POST",
3247
+ path: c.req.path,
3248
+ model: originalModel,
3249
+ resolvedModel,
3250
+ status: error.response.status,
3251
+ errorBody
3252
+ }, selectedModel, startTime);
3253
+ }
3254
+ throw error;
3255
+ }
3256
+ const contentType = response.headers.get("content-type") ?? "";
3257
+ const clientAcceptsSSE = (c.req.header("accept") ?? "").includes("text/event-stream");
3258
+ let isStreaming = contentType.includes("text/event-stream");
3259
+ if (!isStreaming && clientAcceptsSSE) {
3260
+ if (contentType === "" || contentType === "application/octet-stream") {
3261
+ consola.warn(`Upstream /v1/messages returned status=${response.status} content-type=${JSON.stringify(contentType)} but client requested streaming; treating response body as SSE`);
3262
+ isStreaming = true;
3263
+ }
3264
+ }
3265
+ if (debugEnabled) consola.debug(`Upstream /v1/messages: status=${response.status} content-type="${contentType}" isStreaming=${isStreaming}`);
3266
+ if (isStreaming) {
3267
+ logRequest({
3268
+ method: "POST",
3269
+ path: c.req.path,
3270
+ model: originalModel,
3271
+ resolvedModel,
3272
+ status: response.status,
3273
+ streaming: true
3274
+ }, selectedModel, startTime);
3275
+ if (debugEnabled) consola.debug("Streaming response from Copilot /v1/messages");
3276
+ const streamHeaders = {
3277
+ "content-type": "text/event-stream",
3278
+ "cache-control": "no-cache",
3279
+ "transfer-encoding": "chunked",
3280
+ connection: "keep-alive"
3281
+ };
3282
+ const requestId = response.headers.get("x-request-id");
3283
+ if (requestId) streamHeaders["x-request-id"] = requestId;
3284
+ const reqId = response.headers.get("request-id");
3285
+ if (reqId) streamHeaders["request-id"] = reqId;
3286
+ if (advisorEnabled && response.body) {
3287
+ let parsedBase = {};
3288
+ try {
3289
+ parsedBase = JSON.parse(resolvedBody);
3290
+ } catch {}
3291
+ const initialConversation = Array.isArray(parsedBase.messages) ? parsedBase.messages : [];
3292
+ return new Response(buildAdvisorStream({
3293
+ firstResponse: response,
3294
+ initialConversation,
3295
+ baseBody: parsedBase,
3296
+ requestHeaders: {
3297
+ ...selectedModel?.requestHeaders,
3298
+ ...effectiveBetas
3299
+ },
3300
+ externalAborter: advisorAborter
3301
+ }), {
3302
+ status: response.status,
3303
+ headers: streamHeaders
3304
+ });
3305
+ }
3306
+ return new Response(response.body ? relayAnthropicStream(response.body, { routePath: c.req.path }) : null, {
3307
+ status: response.status,
3308
+ headers: streamHeaders
3309
+ });
3310
+ }
3311
+ const cappedResult = await readResponseBodyCapped(response, c.req.path, MAX_RESPONSE_BODY_BYTES);
3312
+ if (!cappedResult.ok) return c.json(cappedResult.errorResponse, cappedResult.status);
3313
+ const responseBody = cappedResult.value;
3314
+ const usage = responseBody.usage;
3315
+ logRequest({
3316
+ method: "POST",
3317
+ path: c.req.path,
3318
+ model: originalModel,
3319
+ resolvedModel,
3320
+ inputTokens: usage?.input_tokens,
3321
+ outputTokens: usage?.output_tokens,
3322
+ status: response.status
3323
+ }, selectedModel, startTime);
3324
+ if (debugEnabled) consola.debug("Non-streaming response from Copilot /v1/messages:", JSON.stringify(responseBody).slice(0, 2e3));
3325
+ const xRequestId = response.headers.get("x-request-id");
3326
+ if (xRequestId) c.header("x-request-id", xRequestId);
3327
+ const requestIdHeader = response.headers.get("request-id");
3328
+ if (requestIdHeader) c.header("request-id", requestIdHeader);
3329
+ return c.json(responseBody, response.status);
3330
+ }
3331
+ /**
3332
+ * Parse the JSON body, resolve the model name, sanitize cache_control
3333
+ * fields, translate thinking-mode shape for adaptive-thinking models,
3334
+ * and re-serialize. Returns the body string, original/resolved model
3335
+ * names, and the matching model metadata (if any).
3336
+ *
3337
+ * Re-serialization is skipped when no modifications are needed.
3338
+ */
3339
+ function resolveModelInBody$1(rawBody) {
3340
+ let parsed;
3341
+ try {
3342
+ parsed = JSON.parse(rawBody);
3343
+ } catch {
3344
+ return { body: rawBody };
3345
+ }
3346
+ const originalModel = typeof parsed.model === "string" ? parsed.model : void 0;
3347
+ let modified = false;
3348
+ if (originalModel) {
3349
+ const resolved = resolveModel(originalModel);
3350
+ if (resolved !== originalModel) {
3351
+ parsed.model = resolved;
3352
+ modified = true;
3353
+ }
3354
+ }
3355
+ const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
3356
+ const selectedModel = resolvedModel ? state.models?.data.find((m) => m.id === resolvedModel) : void 0;
3357
+ if (translateThinking(parsed, selectedModel)) modified = true;
3358
+ if (clampOutputConfigEffortInPlace(parsed, selectedModel)) modified = true;
3359
+ if (rawBody.includes("\"scope\"") && sanitizeCacheControl$1(parsed)) modified = true;
3360
+ if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"") || rawBody.includes("\"eager_input_streaming\"")) && stripAnthropicOnlyFields$1(parsed)) modified = true;
3361
+ return {
3362
+ body: modified ? JSON.stringify(parsed) : rawBody,
3363
+ originalModel,
3364
+ resolvedModel,
3365
+ selectedModel
3366
+ };
3367
+ }
3368
+ /**
3369
+ * Clamp `body.output_config.effort` to the model's
3370
+ * `capabilities.supports.reasoning_effort` allowlist. Mutates `body`
3371
+ * in place. Returns true iff a clamp was applied.
3372
+ *
3373
+ * Sibling to `translateThinking`'s internal clamp — that one only fires
3374
+ * when the request arrives in the Anthropic `thinking:{type:"enabled"}`
3375
+ * shape (which the translator converts into `output_config.effort`).
3376
+ * Requests that arrive ALREADY in Copilot shape (`output_config.effort`
3377
+ * set by the client) would otherwise pass through unclamped and 400 at
3378
+ * upstream — the failure mode is exactly the one Claude Code agent-teams
3379
+ * teammates hit on opus-4.8 with `xhigh` effort (Copilot rejects with
3380
+ * "output_config.effort 'xhigh' is not supported by model
3381
+ * claude-opus-4.8; supported values: [medium]").
3382
+ *
3383
+ * Generic policy: the proxy does not forward a value upstream rejects.
3384
+ * If the model declares a `reasoning_effort` allowlist and the
3385
+ * client-supplied `output_config.effort` is not in it, clamp via
3386
+ * `clampEffort` (using `EFFORT_ORDER` bucketing). Unknown effort
3387
+ * values fall through to `clampEffort`'s "no closer tier" branch
3388
+ * (returns the original); the model would then 400 at upstream, which
3389
+ * is the right behaviour for genuinely invalid input.
3390
+ *
3391
+ * No-ops when:
3392
+ * - The model has no `reasoning_effort` allowlist (some models
3393
+ * accept arbitrary efforts; treat absent allowlist as "any
3394
+ * accepted")
3395
+ * - `body.output_config` is missing or not a plain object
3396
+ * - `body.output_config.effort` is missing or not a string
3397
+ * - The current effort is already in the allowlist (no-op clamp)
3398
+ */
3399
+ function clampOutputConfigEffortInPlace(body, model) {
3400
+ if (!model?.capabilities?.supports?.reasoning_effort) return false;
3401
+ const supported = model.capabilities.supports.reasoning_effort;
3402
+ if (!Array.isArray(supported) || supported.length === 0) return false;
3403
+ if (!body.output_config || typeof body.output_config !== "object") return false;
3404
+ const oc = body.output_config;
3405
+ const current = oc.effort;
3406
+ if (typeof current !== "string") return false;
3407
+ if (supported.includes(current)) return false;
3408
+ const clamped = clampEffort(EFFORT_ORDER.includes(current) ? current : "xhigh", supported);
3409
+ if (clamped === current) return false;
3410
+ oc.effort = clamped;
3411
+ return true;
3412
+ }
3413
+ /**
3414
+ * Translate Anthropic-shape `thinking:{type:"enabled", budget_tokens}` to
3415
+ * Copilot-shape `thinking:{type:"adaptive"}` + `output_config.effort`
3416
+ * when the resolved model declares `adaptive_thinking: true`.
3417
+ *
3418
+ * Returns true if the body was modified. No-op when the model doesn't
3419
+ * support adaptive thinking, when thinking is missing/disabled/already
3420
+ * adaptive, or when `body` isn't a plain object. Client-supplied
3421
+ * `output_config.effort` always wins over the bucketed value.
3422
+ */
3423
+ function translateThinking(body, model) {
3424
+ if (!model?.capabilities?.supports?.adaptive_thinking) return false;
3425
+ const thinking = body.thinking;
3426
+ if (!thinking || typeof thinking !== "object") return false;
3427
+ const t = thinking;
3428
+ if (t.type !== "enabled") return false;
3429
+ const bucketed = bucketEffort(t.budget_tokens);
3430
+ const supported = model.capabilities.supports.reasoning_effort;
3431
+ const effort = Array.isArray(supported) && supported.length > 0 ? clampEffort(bucketed, supported) : bucketed;
3432
+ body.thinking = { type: "adaptive" };
3433
+ const existing = body.output_config && typeof body.output_config === "object" ? body.output_config : {};
3434
+ body.output_config = {
3435
+ ...existing,
3436
+ effort: existing.effort ?? effort
3437
+ };
3438
+ return true;
3439
+ }
3440
+ /**
3441
+ * Strip the `scope` field from all `cache_control` objects in the body.
3442
+ * Claude CLI 2.1.88+ sends {"type":"ephemeral","scope":"global"} which
3443
+ * Copilot rejects. Mutates the parsed object in place.
3444
+ *
3445
+ * Covers: system blocks, message content blocks (including nested
3446
+ * tool_result content), and tool definitions.
3447
+ */
3448
+ function sanitizeCacheControl$1(body) {
3449
+ let stripped = false;
3450
+ function stripScope(block) {
3451
+ const cc = block.cache_control;
3452
+ if (cc?.scope !== void 0) {
3453
+ delete cc.scope;
3454
+ if (Object.keys(cc).length === 0) delete block.cache_control;
3455
+ stripped = true;
3456
+ }
3457
+ }
3458
+ if (Array.isArray(body.system)) for (const block of body.system) stripScope(block);
3459
+ if (Array.isArray(body.messages)) {
3460
+ for (const msg of body.messages) if (Array.isArray(msg.content)) for (const block of msg.content) {
3461
+ stripScope(block);
3462
+ if (Array.isArray(block.content)) for (const nested of block.content) stripScope(nested);
3463
+ }
3464
+ }
3465
+ if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
3466
+ return stripped;
3467
+ }
3468
+ /**
3469
+ * Apply default anthropic-beta values for Claude models when the client
3470
+ * (e.g. curl) sends no beta headers. Claude CLI sends its own betas,
3471
+ * so this only fires as a safety net for bare clients.
3472
+ */
3473
+ function applyDefaultBetas(betaHeaders, modelId) {
3474
+ if (betaHeaders["anthropic-beta"]) return betaHeaders;
3475
+ if (!modelId || !modelId.startsWith("claude-")) return betaHeaders;
3476
+ return {
3477
+ ...betaHeaders,
3478
+ "anthropic-beta": ["interleaved-thinking-2025-05-14", "context-management-2025-06-27"].join(",")
3479
+ };
3480
+ }
3481
+ /**
3482
+ * Strip top-level body fields that Anthropic's Messages API accepts but
3483
+ * Copilot rejects with HTTP 400 "Extra inputs are not permitted". Mutates
3484
+ * `body` in place; returns true if anything was stripped.
3485
+ *
3486
+ * Empirical verification (2026-05-11):
3487
+ * POST /v1/messages?beta=true { ..., budget: {total_tokens: 10000} } → 400
3488
+ * POST /v1/messages?beta=true { ..., output_config: {schema: {...}} } → 400
3489
+ * POST /v1/messages?beta=true { ..., betas: ["..."] } → 400
3490
+ *
3491
+ * Each strip emits a one-line consola.warn so users running with these
3492
+ * features (e.g. `claude --max-budget-usd`, `--json-schema`) understand
3493
+ * the request succeeds with the *body field* dropped — semantics may
3494
+ * differ from upstream Anthropic. The corresponding `anthropic-beta`
3495
+ * header is preserved (Phase A allowlist) so the *intent* still flows
3496
+ * to Copilot, even if the per-request enforcement field is gone.
3497
+ *
3498
+ * NOT stripped here:
3499
+ * - `mcp_servers` (Phase G translate path — silent strip causes LLM
3500
+ * to hallucinate tools per gemini-critic finding)
3501
+ * - `metadata` (Copilot 200s, ignores harmlessly)
3502
+ */
3503
+ function stripAnthropicOnlyFields$1(body) {
3504
+ let stripped = false;
3505
+ if (body.budget !== void 0) {
3506
+ consola.warn("Stripping body-level `budget` field (Copilot 400s; the `task-budgets-` beta header is preserved but cost ceiling is not enforced server-side)");
3507
+ delete body.budget;
3508
+ stripped = true;
3509
+ }
3510
+ if (body.output_config !== void 0) {
3511
+ if (body.output_config && typeof body.output_config === "object") {
3512
+ const oc = body.output_config;
3513
+ const PROXY_OWNED_FIELDS = /* @__PURE__ */ new Set(["effort"]);
3514
+ const schema = oc.schema;
3515
+ const ocType = oc.type;
3516
+ let strippedAny = false;
3517
+ for (const key of Object.keys(oc)) if (!PROXY_OWNED_FIELDS.has(key)) {
3518
+ delete oc[key];
3519
+ strippedAny = true;
3520
+ }
3521
+ if (strippedAny) {
3522
+ consola.warn("Stripping client-set `output_config` Structured-Outputs fields (Copilot 400s on `output_config.*` other than `effort`; injecting schema as system-prompt instruction so the model still produces JSON conforming to the structured-outputs schema, since server-side enforcement is gone)");
3523
+ if (Object.keys(oc).length === 0) delete body.output_config;
3524
+ if (schema !== void 0 || ocType === "json_object") appendStructuredOutputInstruction(body, schema, ocType);
3525
+ stripped = true;
3526
+ }
3527
+ }
3528
+ }
3529
+ if (Array.isArray(body.betas)) {
3530
+ consola.warn("Stripping body-level `betas` array (Copilot 400s; the betas are conveyed via the `anthropic-beta` header instead)");
3531
+ delete body.betas;
3532
+ stripped = true;
3533
+ }
3534
+ if (Array.isArray(body.tools)) {
3535
+ let warnedFGTS = false;
3536
+ for (const tool of body.tools) if (typeof tool === "object" && tool !== null) {
3537
+ const t = tool;
3538
+ if (t.eager_input_streaming !== void 0) {
3539
+ delete t.eager_input_streaming;
3540
+ stripped = true;
3541
+ if (!warnedFGTS) {
3542
+ consola.warn("Stripping per-tool `eager_input_streaming` field (Copilot 400s on `tools.*.custom.eager_input_streaming`; FGTS chunk-size optimization disabled, but streaming correctness is unaffected — `input_json_delta` events still flow normally)");
3543
+ warnedFGTS = true;
3544
+ }
3545
+ }
3546
+ }
3547
+ }
3548
+ return stripped;
3549
+ }
3550
+ /**
3551
+ * Append a system-prompt instruction telling the model to produce JSON
3552
+ * conforming to a Structured Outputs schema. Used after the proxy
3553
+ * strips `output_config` to preserve the schema enforcement intent
3554
+ * via prompt engineering instead of server-side validation.
3555
+ *
3556
+ * Mutates `body.system` in place. Handles both string and array shapes
3557
+ * (Anthropic spec allows either).
3558
+ */
3559
+ function appendStructuredOutputInstruction(body, schema, ocType) {
3560
+ let instruction = "\n\nIMPORTANT: Your response MUST be a single valid JSON object. Do not wrap it in markdown code fences. Do not include any text before or after the JSON object.";
3561
+ if (schema !== void 0) instruction += ` The JSON object MUST conform to this JSON Schema:\n${JSON.stringify(schema)}`;
3562
+ else if (typeof ocType === "string") instruction += ` Output type requested: ${ocType}.`;
3563
+ if (typeof body.system === "string") body.system = body.system + instruction;
3564
+ else if (Array.isArray(body.system)) body.system = [...body.system, {
3565
+ type: "text",
3566
+ text: instruction.trimStart()
3567
+ }];
3568
+ else body.system = instruction.trimStart();
3569
+ }
3570
+ //#endregion
3571
+ //#region src/routes/messages/count-tokens-handler.ts
3572
+ const isWebSearchTool = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
3573
+ /**
3574
+ * Strip web_search tools from the request body before forwarding
3575
+ * to Copilot's count_tokens endpoint, which rejects unknown tool types.
3576
+ * Returns the original raw body if no web_search tools are present.
3577
+ */
3578
+ function stripWebSearchFromBody(rawBody) {
3579
+ if (!rawBody.includes("web_search")) return rawBody;
3580
+ let body;
3581
+ try {
3582
+ body = JSON.parse(rawBody);
3583
+ } catch {
3584
+ return rawBody;
3585
+ }
3586
+ if (!body.tools?.some((tool) => isWebSearchTool(tool))) return rawBody;
3587
+ body.tools = body.tools.filter((tool) => !isWebSearchTool(tool));
3588
+ if (body.tools.length === 0) {
3589
+ body.tools = void 0;
3590
+ body.tool_choice = void 0;
3591
+ } else if (body.tool_choice && typeof body.tool_choice === "object" && body.tool_choice.type === "tool") {
3592
+ const choiceName = body.tool_choice.name;
3593
+ if (choiceName && !body.tools.some((tool) => tool.name === choiceName)) body.tool_choice = { type: "auto" };
3594
+ }
3595
+ return JSON.stringify(body);
3596
+ }
3597
+ /**
3598
+ * Passthrough handler for Anthropic token counting.
3599
+ * Strips web_search tools and forwards beta headers to Copilot's
3600
+ * native /v1/messages/count_tokens endpoint.
3601
+ */
3602
+ async function handleCountTokens(c) {
3603
+ const startTime = Date.now();
3604
+ const strippedBody = stripWebSearchFromBody(sanitizeAnthropicBody(await c.req.text()));
3605
+ if (strippedBody.includes("\"mcp_servers\"")) try {
3606
+ const probe = JSON.parse(strippedBody);
3607
+ if (Array.isArray(probe.mcp_servers) && probe.mcp_servers.length > 0) return c.json({
3608
+ type: "error",
3609
+ error: {
3610
+ type: "invalid_request_error",
3611
+ message: "Inline `mcp_servers` body field is not supported by github-router. Configure remote MCP servers as local stdio entries in `~/.claude/mcp.json` instead."
3612
+ }
3613
+ }, 400);
3614
+ } catch {}
3615
+ const { body: finalBody, originalModel, resolvedModel } = resolveModelInBody(strippedBody);
3616
+ const extraHeaders = {};
3617
+ const anthropicBeta = c.req.header("anthropic-beta");
3618
+ if (anthropicBeta) {
3619
+ const filtered = filterBetaHeader(anthropicBeta);
3620
+ if (filtered) extraHeaders["anthropic-beta"] = filtered;
3621
+ }
3622
+ const modelId = resolvedModel ?? originalModel;
3623
+ const selectedModel = state.models?.data.find((m) => m.id === modelId);
3624
+ const response = await countTokens(finalBody, {
3625
+ ...selectedModel?.requestHeaders,
3626
+ ...extraHeaders
3627
+ }, void 0, true);
3628
+ const responseBody = await parseJsonOrDiagnose(response, c.req.path);
3629
+ logRequest({
3630
+ method: "POST",
3631
+ path: c.req.path,
3632
+ model: originalModel,
3633
+ resolvedModel,
3634
+ inputTokens: responseBody.input_tokens,
3635
+ status: response.status
3636
+ }, selectedModel, startTime);
3637
+ return c.json(responseBody);
3638
+ }
3639
+ /**
3640
+ * Parse the JSON body, resolve the model name, sanitize cache_control, and re-serialize.
3641
+ */
3642
+ function resolveModelInBody(rawBody) {
3643
+ let parsed;
3644
+ try {
3645
+ parsed = JSON.parse(rawBody);
3646
+ } catch {
3647
+ return { body: rawBody };
3648
+ }
3649
+ const originalModel = typeof parsed.model === "string" ? parsed.model : void 0;
3650
+ let modified = false;
3651
+ if (originalModel) {
3652
+ const resolved = resolveModel(originalModel);
3653
+ if (resolved !== originalModel) {
3654
+ parsed.model = resolved;
3655
+ modified = true;
3656
+ }
3657
+ }
3658
+ if (rawBody.includes("\"scope\"") && sanitizeCacheControl(parsed)) modified = true;
3659
+ if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"") || rawBody.includes("\"eager_input_streaming\"")) && stripAnthropicOnlyFields(parsed)) modified = true;
3660
+ const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
3661
+ const selectedModel = resolvedModel ? state.models?.data.find((m) => m.id === resolvedModel) : void 0;
3662
+ if (selectedModel && clampOutputConfigEffortInPlace(parsed, selectedModel)) modified = true;
3663
+ return {
3664
+ body: modified ? JSON.stringify(parsed) : rawBody,
3665
+ originalModel,
3666
+ resolvedModel
3667
+ };
3668
+ }
3669
+ function sanitizeCacheControl(body) {
3670
+ let stripped = false;
3671
+ function stripScope(block) {
3672
+ if (block.cache_control?.scope !== void 0) {
3673
+ delete block.cache_control.scope;
3674
+ if (Object.keys(block.cache_control).length === 0) delete block.cache_control;
3675
+ stripped = true;
3676
+ }
3677
+ }
3678
+ if (Array.isArray(body.system)) for (const block of body.system) stripScope(block);
3679
+ if (Array.isArray(body.messages)) {
3680
+ for (const msg of body.messages) if (Array.isArray(msg.content)) for (const block of msg.content) {
3681
+ stripScope(block);
3682
+ if (Array.isArray(block.content)) for (const nested of block.content) stripScope(nested);
3683
+ }
3684
+ }
3685
+ if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
3686
+ return stripped;
3687
+ }
3688
+ /**
3689
+ * Strip top-level body fields Copilot 400s on (budget, output_config.schema,
3690
+ * betas). Duplicated structurally from handler.ts because count_tokens uses
3691
+ * its own JSON-pass; the bodies are independent. Behavior must stay in lock-
3692
+ * step with handler.ts's stripAnthropicOnlyFields — covered by integration
3693
+ * tests (Phase F P2.4).
3694
+ */
3695
+ function stripAnthropicOnlyFields(body) {
3696
+ let stripped = false;
3697
+ if (body.budget !== void 0) {
3698
+ consola.warn("[count_tokens] Stripping body-level `budget` field (Copilot 400s)");
3699
+ delete body.budget;
3700
+ stripped = true;
3701
+ }
3702
+ if (body.output_config !== void 0) {
3703
+ if (body.output_config && typeof body.output_config === "object") {
3704
+ const oc = body.output_config;
3705
+ const PROXY_OWNED_FIELDS = /* @__PURE__ */ new Set(["effort"]);
3706
+ let strippedAny = false;
3707
+ for (const key of Object.keys(oc)) if (!PROXY_OWNED_FIELDS.has(key)) {
3708
+ delete oc[key];
3709
+ strippedAny = true;
3710
+ }
3711
+ if (strippedAny) {
3712
+ consola.warn("[count_tokens] Stripping client-set `output_config` Structured-Outputs fields (Copilot 400s on `output_config.*` other than `effort`)");
3713
+ if (Object.keys(oc).length === 0) delete body.output_config;
3714
+ stripped = true;
3715
+ }
3716
+ }
3717
+ }
3718
+ if (Array.isArray(body.betas)) {
3719
+ consola.warn("[count_tokens] Stripping body-level `betas` array (Copilot 400s; conveyed via header)");
3720
+ delete body.betas;
3721
+ stripped = true;
3722
+ }
3723
+ if (Array.isArray(body.tools)) {
3724
+ let warnedFGTS = false;
3725
+ for (const tool of body.tools) if (typeof tool === "object" && tool !== null) {
3726
+ const t = tool;
3727
+ if (t.eager_input_streaming !== void 0) {
3728
+ delete t.eager_input_streaming;
3729
+ stripped = true;
3730
+ if (!warnedFGTS) {
3731
+ consola.warn("[count_tokens] Stripping per-tool `eager_input_streaming` (Copilot 400s on `tools.*.custom.eager_input_streaming`)");
3732
+ warnedFGTS = true;
3733
+ }
3734
+ }
3735
+ }
3736
+ }
3737
+ return stripped;
3738
+ }
3739
+ //#endregion
3740
+ //#region src/routes/messages/route.ts
3741
+ const messageRoutes = new Hono();
3742
+ messageRoutes.post("/", async (c) => {
3743
+ try {
3744
+ return await handleCompletion(c);
3745
+ } catch (error) {
3746
+ return await forwardError(c, error);
3747
+ }
3748
+ });
3749
+ messageRoutes.post("/count_tokens", async (c) => {
3750
+ try {
3751
+ return await handleCountTokens(c);
3752
+ } catch (error) {
3753
+ return await forwardError(c, error);
3754
+ }
3755
+ });
3756
+ //#endregion
3757
+ //#region src/routes/models/route.ts
3758
+ const modelRoutes = new Hono();
3759
+ modelRoutes.get("/", async (c) => {
3760
+ try {
3761
+ if (!state.models) await cacheModels();
3762
+ const models = state.models?.data.map((model) => {
3763
+ const { requestHeaders, ...rest } = model;
3764
+ return {
3765
+ ...rest,
3766
+ object: "model",
3767
+ type: model.capabilities?.type ?? "model",
3768
+ created: 0,
3769
+ created_at: (/* @__PURE__ */ new Date(0)).toISOString(),
3770
+ owned_by: model.vendor,
3771
+ display_name: model.name
3772
+ };
3773
+ });
3774
+ return c.json({
3775
+ object: "list",
3776
+ data: models,
3777
+ has_more: false
3778
+ });
3779
+ } catch (error) {
3780
+ return await forwardError(c, error);
3781
+ }
3782
+ });
3783
+ //#endregion
3784
+ //#region src/routes/responses/handler.ts
3785
+ const ENCODER = new TextEncoder();
3786
+ function formatSSE(chunk) {
3787
+ const parts = [];
3788
+ if (chunk.event) parts.push(`event: ${chunk.event}`);
3789
+ if (chunk.data !== void 0) for (const line of String(chunk.data).split(/\r\n|\r|\n/)) parts.push(`data: ${line}`);
3790
+ if (chunk.id !== void 0) parts.push(`id: ${String(chunk.id)}`);
3791
+ return parts.join("\n") + "\n\n";
3792
+ }
3793
+ async function handleResponses(c) {
3794
+ const startTime = Date.now();
3795
+ await checkRateLimit(state);
3796
+ const payload = await c.req.json();
3797
+ const debugEnabled = consola.level >= 4;
3798
+ if (debugEnabled) consola.debug("Responses request payload:", JSON.stringify(payload).slice(-400));
3799
+ const originalModel = payload.model;
3800
+ const resolvedModel = resolveModel(payload.model);
3801
+ if (resolvedModel !== payload.model) payload.model = resolvedModel;
3802
+ const selectedModel = state.models?.data.find((model) => model.id === payload.model);
3803
+ logEndpointMismatch(payload.model, "/responses");
3804
+ if (state.manualApprove) await awaitApproval();
3805
+ await injectWebSearchIfNeeded(payload);
3806
+ const response = await createResponses(payload, selectedModel?.requestHeaders, void 0, true).catch(async (error) => {
3807
+ if (error instanceof HTTPError) {
3808
+ const errorBody = await error.response.clone().text().catch(() => "");
3809
+ logRequest({
3810
+ method: "POST",
3811
+ path: c.req.path,
3812
+ model: originalModel,
3813
+ resolvedModel,
3814
+ status: error.response.status,
3815
+ errorBody
3816
+ }, selectedModel, startTime);
3817
+ }
3818
+ throw error;
3819
+ });
3820
+ const isStreaming = !isNonStreaming(response);
3821
+ logRequest({
3822
+ method: "POST",
3823
+ path: c.req.path,
3824
+ model: originalModel,
3825
+ resolvedModel,
3826
+ status: 200,
3827
+ streaming: isStreaming
3828
+ }, selectedModel, startTime);
3829
+ if (!isStreaming) {
3830
+ if (debugEnabled) consola.debug("Non-streaming response:", JSON.stringify(response));
3831
+ return c.json(response);
3832
+ }
3833
+ const iterator = response[Symbol.asyncIterator]();
3834
+ let firstChunk;
3835
+ let upstreamFinished = false;
3836
+ while (true) {
3837
+ const r = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
3838
+ if (r.done) {
3839
+ upstreamFinished = true;
3840
+ break;
3841
+ }
3842
+ if (r.value === void 0 || r.value === null) continue;
3843
+ if (r.value.data === "[DONE]") {
3844
+ upstreamFinished = true;
3845
+ break;
3846
+ }
3847
+ if (!r.value.data) continue;
3848
+ firstChunk = r.value;
3849
+ break;
3850
+ }
3851
+ if (firstChunk === void 0) consola.warn(`Upstream /responses returned no payload events at ${c.req.path}`);
3852
+ let pendingFirstChunk = firstChunk;
3853
+ let consumerCancelled = false;
3854
+ const safeClose = (controller) => {
3855
+ try {
3856
+ controller.close();
3857
+ } catch {}
3858
+ };
3859
+ const releaseUpstream = (reason) => {
3860
+ if (typeof iterator.return === "function") iterator.return(reason).catch(() => {});
3861
+ };
3862
+ const safeEnqueue = (controller, bytes) => {
3863
+ try {
3864
+ controller.enqueue(bytes);
3865
+ return true;
3866
+ } catch (e) {
3867
+ if (isControllerClosedError(e)) {
3868
+ consumerCancelled = true;
3869
+ releaseUpstream(e);
3870
+ return false;
3871
+ }
3872
+ throw e;
3873
+ }
3874
+ };
3875
+ return new Response(new ReadableStream({
3876
+ async pull(controller) {
3877
+ if (consumerCancelled || upstreamFinished) {
3878
+ safeClose(controller);
3879
+ return;
3880
+ }
3881
+ if (pendingFirstChunk !== void 0) {
3882
+ const chunk = pendingFirstChunk;
3883
+ pendingFirstChunk = void 0;
3884
+ if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(chunk));
3885
+ safeEnqueue(controller, ENCODER.encode(formatSSE(chunk)));
3886
+ return;
3887
+ }
3888
+ try {
3889
+ const result = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
3890
+ if (consumerCancelled) {
3891
+ safeClose(controller);
3892
+ return;
3893
+ }
3894
+ if (result.done) {
3895
+ upstreamFinished = true;
3896
+ safeClose(controller);
3897
+ return;
3898
+ }
3899
+ if (result.value === void 0 || result.value === null) return;
3900
+ if (result.value.data === "[DONE]") {
3901
+ upstreamFinished = true;
3902
+ safeClose(controller);
3903
+ return;
3904
+ }
3905
+ if (!result.value.data) return;
3906
+ if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(result.value));
3907
+ safeEnqueue(controller, ENCODER.encode(formatSSE(result.value)));
3908
+ } catch (error) {
3909
+ upstreamFinished = true;
3910
+ if (consumerCancelled) {
3911
+ releaseUpstream(error);
3912
+ safeClose(controller);
3913
+ return;
3914
+ }
3915
+ const { errName, errMessage } = logStreamError(c.req.path, error);
3916
+ safeEnqueue(controller, ENCODER.encode(buildOpenAIErrorEvent(errName, errMessage)));
3917
+ releaseUpstream(error);
3918
+ safeClose(controller);
3919
+ }
3920
+ },
3921
+ cancel() {
3922
+ consumerCancelled = true;
3923
+ upstreamFinished = true;
3924
+ releaseUpstream();
3925
+ }
3926
+ }), {
3927
+ status: 200,
3928
+ headers: {
3929
+ "content-type": "text/event-stream",
3930
+ "cache-control": "no-cache",
3931
+ "transfer-encoding": "chunked",
3932
+ connection: "keep-alive"
3933
+ }
3934
+ });
3935
+ }
3936
+ const isNonStreaming = (response) => Object.hasOwn(response, "output");
3937
+ async function injectWebSearchIfNeeded(payload) {
3938
+ if (!payload.tools?.some((t) => t.type === "web_search")) return;
3939
+ if (Array.isArray(payload.input)) {
3940
+ if (payload.input.some((item) => item.type === "function_call_output")) return;
3941
+ }
3942
+ const query = extractUserQuery(payload.input);
3943
+ if (query) try {
3944
+ const results = await searchWeb(query);
3945
+ const searchContext = [
3946
+ "[Web Search Results]",
3947
+ results.content,
3948
+ "",
3949
+ results.references.map((r) => `- [${r.title}](${r.url})`).join("\n"),
3950
+ "[End Web Search Results]"
3951
+ ].join("\n");
3952
+ payload.instructions = payload.instructions ? `${searchContext}\n\n${payload.instructions}` : searchContext;
3953
+ } catch (error) {
3954
+ consola.warn("Web search failed, continuing without results:", error);
3955
+ }
3956
+ payload.tools = payload.tools?.filter((t) => t.type !== "web_search");
3957
+ if (payload.tools && payload.tools.length === 0) payload.tools = void 0;
3958
+ if (!payload.tools) payload.tool_choice = void 0;
3959
+ else if (payload.tool_choice && typeof payload.tool_choice === "object") {
3960
+ const choice = payload.tool_choice;
3961
+ if ((choice.function?.name ?? choice.name) === "web_search") payload.tool_choice = void 0;
3962
+ }
3963
+ }
3964
+ function extractUserQuery(input) {
3965
+ if (typeof input === "string") return input;
3966
+ if (!Array.isArray(input)) return void 0;
3967
+ for (let i = input.length - 1; i >= 0; i--) {
3968
+ const item = input[i];
3969
+ if ("role" in item && item.role === "user") {
3970
+ if (typeof item.content === "string") return item.content;
3971
+ if (Array.isArray(item.content)) {
3972
+ const text = item.content.find((p) => p.type === "input_text");
3973
+ if (text && "text" in text) return text.text;
3974
+ }
3975
+ }
3976
+ }
3977
+ }
3978
+ /**
3979
+ * Compaction prompt used when GitHub Copilot API does not support
3980
+ * /responses/compact natively. Matches the prompt Codex CLI uses for
3981
+ * local (non-OpenAI) compaction.
3982
+ */
3983
+ const COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
3984
+
3985
+ Include:
3986
+ - Current progress and key decisions made
3987
+ - Important context, constraints, or user preferences
3988
+ - What remains to be done (clear next steps)
3989
+ - Any critical data, examples, or references needed to continue
3990
+
3991
+ Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
3992
+ async function handleResponsesCompact(c) {
3993
+ const startTime = Date.now();
3994
+ await checkRateLimit(state);
3995
+ if (!state.copilotToken) throw new Error("Copilot token not found");
3996
+ if (state.manualApprove) await awaitApproval();
3997
+ const body = await c.req.json();
3998
+ const compactUrl = `${copilotBaseUrl(state)}/responses/compact`;
3999
+ const doFetch = () => fetch(compactUrl, {
4000
+ method: "POST",
4001
+ headers: copilotHeaders(state),
4002
+ body: JSON.stringify(body),
4003
+ signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS || 3e5)
4004
+ });
4005
+ const response = await fetchWithTransientRetry(() => tryRefreshAndRetry(doFetch, "/responses/compact"), { label: "/responses/compact" });
4006
+ if (response.ok) {
4007
+ logRequest({
4008
+ method: "POST",
4009
+ path: c.req.path,
4010
+ status: 200
4011
+ }, void 0, startTime);
4012
+ return c.json(await response.json());
4013
+ }
4014
+ if (response.status === 404) {
4015
+ consola.debug("Copilot API does not support /responses/compact, using synthetic compaction");
4016
+ await response.body?.cancel().catch(() => {});
4017
+ return await syntheticCompact(c, body, startTime);
4018
+ }
4019
+ logRequest({
4020
+ method: "POST",
4021
+ path: c.req.path,
4022
+ status: response.status
4023
+ }, void 0, startTime);
4024
+ throw new HTTPError("Copilot responses/compact request failed", response);
4025
+ }
4026
+ /**
4027
+ * Synthetic compaction: sends the conversation history to Copilot's
4028
+ * regular /responses endpoint with a compaction prompt appended,
4029
+ * then returns the model's summary in the compact response format.
4030
+ */
4031
+ async function syntheticCompact(c, body, startTime) {
4032
+ const input = Array.isArray(body.input) ? [...body.input] : [];
4033
+ input.push({
4034
+ type: "message",
4035
+ role: "user",
4036
+ content: [{
4037
+ type: "input_text",
4038
+ text: COMPACTION_PROMPT
4039
+ }]
4040
+ });
4041
+ const payload = {
4042
+ model: body.model,
4043
+ input,
4044
+ instructions: body.instructions,
4045
+ stream: false,
4046
+ store: false
4047
+ };
4048
+ let result;
4049
+ try {
4050
+ result = await createResponses(payload, void 0, void 0, true);
4051
+ } catch (error) {
4052
+ if (error instanceof HTTPError) logRequest({
4053
+ method: "POST",
4054
+ path: c.req.path,
4055
+ status: error.response.status
4056
+ }, void 0, startTime);
4057
+ throw error;
4058
+ }
4059
+ logRequest({
4060
+ method: "POST",
4061
+ path: c.req.path,
4062
+ status: 200
4063
+ }, void 0, startTime);
4064
+ return c.json({
4065
+ id: `resp_compact_${randomUUID().replace(/-/g, "").slice(0, 24)}`,
4066
+ object: "response.compaction",
4067
+ created_at: Math.floor(Date.now() / 1e3),
4068
+ output: result.output,
4069
+ usage: result.usage ?? {
4070
+ input_tokens: 0,
4071
+ output_tokens: 0,
4072
+ total_tokens: 0
4073
+ }
4074
+ });
4075
+ }
4076
+ //#endregion
4077
+ //#region src/routes/responses/route.ts
4078
+ const responsesRoutes = new Hono();
4079
+ responsesRoutes.post("/", async (c) => {
4080
+ try {
4081
+ return await handleResponses(c);
4082
+ } catch (error) {
4083
+ return await forwardError(c, error);
4084
+ }
4085
+ });
4086
+ responsesRoutes.post("/compact", async (c) => {
4087
+ try {
4088
+ return await handleResponsesCompact(c);
4089
+ } catch (error) {
4090
+ return await forwardError(c, error);
4091
+ }
4092
+ });
4093
+ //#endregion
4094
+ //#region src/routes/search/route.ts
4095
+ const searchRoutes = new Hono();
4096
+ searchRoutes.post("/", async (c) => {
4097
+ try {
4098
+ const { query } = await c.req.json();
4099
+ if (!query || typeof query !== "string") return c.json({ error: { message: "Missing required field: query" } }, 400);
4100
+ const results = await searchWeb(query);
4101
+ return c.json({ results });
4102
+ } catch (error) {
4103
+ return await forwardError(c, error);
4104
+ }
4105
+ });
4106
+ //#endregion
4107
+ //#region src/routes/token/route.ts
4108
+ const tokenRoute = new Hono();
4109
+ tokenRoute.get("/", (c) => {
4110
+ if (!state.showToken) return c.json({ error: {
4111
+ message: "Token endpoint disabled",
4112
+ type: "error"
4113
+ } }, 403);
4114
+ return c.json({ token: state.copilotToken });
4115
+ });
4116
+ //#endregion
4117
+ //#region src/routes/usage/route.ts
4118
+ const usageRoute = new Hono();
4119
+ usageRoute.get("/", async (c) => {
4120
+ try {
4121
+ const usage = await getCopilotUsage();
4122
+ return c.json(usage);
4123
+ } catch (error) {
4124
+ consola.error("Error fetching Copilot usage:", error);
4125
+ return await forwardError(c, error);
4126
+ }
4127
+ });
4128
+ //#endregion
4129
+ //#region src/server.ts
4130
+ assertMcpToolSurfaceConsistent();
4131
+ const server = new Hono();
4132
+ server.use(cors());
4133
+ server.get("/", (c) => c.text("Server running"));
4134
+ server.get("/version", (c) => c.json({
4135
+ name,
4136
+ version,
4137
+ gitSha: process.env.GITHUB_SHA ?? "unknown"
4138
+ }));
4139
+ server.on("HEAD", ["/"], (c) => c.body(null, 200));
4140
+ server.route("/chat/completions", completionRoutes);
4141
+ server.route("/responses", responsesRoutes);
4142
+ server.route("/models", modelRoutes);
4143
+ server.route("/embeddings", embeddingRoutes);
4144
+ server.route("/search", searchRoutes);
4145
+ server.route("/usage", usageRoute);
4146
+ server.route("/token", tokenRoute);
4147
+ server.route("/v1/chat/completions", completionRoutes);
4148
+ server.route("/v1/responses", responsesRoutes);
4149
+ server.route("/v1/models", modelRoutes);
4150
+ server.route("/v1/embeddings", embeddingRoutes);
4151
+ server.route("/v1/search", searchRoutes);
4152
+ server.route("/v1/messages", messageRoutes);
4153
+ server.route("/mcp", mcpRoutes);
4154
+ server.post("/api/event_logging/batch", (c) => c.body(null, 200));
4155
+ server.all("/v1/files/*", (c) => c.json({
4156
+ type: "error",
4157
+ error: {
4158
+ type: "not_found_error",
4159
+ message: "Files API is not supported by github-router (Copilot has no equivalent storage backend). Use the Anthropic API directly for file uploads/downloads."
4160
+ }
4161
+ }, 404));
4162
+ server.notFound((c) => c.json({
4163
+ type: "error",
4164
+ error: {
4165
+ type: "not_found_error",
4166
+ message: `${c.req.method} ${c.req.path} not found`
4167
+ }
4168
+ }, 404));
4169
+ //#endregion
4170
+ //#region src/lib/server-setup.ts
4171
+ const MAX_PORT_RETRIES = 10;
4172
+ /**
4173
+ * Maximum request body the proxy will accept, in bytes.
4174
+ *
4175
+ * Pinned EXPLICITLY because the runtimes disagree by default: bun's `serve`
4176
+ * defaults `maxRequestBodySize` to 128 MB and rejects past it, while
4177
+ * `node:http` (the srvx node adapter) has no body limit at all. Left
4178
+ * unset, the same request succeeds under node and 413s under bun, with
4179
+ * nothing in the response explaining that the runtime is the variable.
4180
+ * Same class of defect as the `idleTimeout` divergence below.
4181
+ *
4182
+ * Note which side that leaves exposed: `dist/main.js` ships
4183
+ * `#!/usr/bin/env node`, so npm-installed users are on the runtime with NO
4184
+ * limit, and bun is mostly the dev/`bun run start` path. The uncapped case
4185
+ * was the shipped one.
4186
+ *
4187
+ * 128 MB — i.e. bun's default, adopted as the intended value on BOTH
4188
+ * runtimes rather than inherited on one:
4189
+ *
4190
+ * - It sits above the traffic this proxy is built for, with margin. A
4191
+ * full 1M-token Claude Code context is on the order of 4-5 MB of JSON
4192
+ * text; base64 inline images inflate 4/3 and are re-sent every turn, so
4193
+ * an image-heavy session runs to tens of MB. That is an estimate, not a
4194
+ * measurement, which is part of why the cap is set well clear of it
4195
+ * rather than snugly above it.
4196
+ * - Removing the limit to "match node" is not free even on loopback. The
4197
+ * app mounts permissive `cors()`, so any web page the user visits can
4198
+ * POST to 127.0.0.1:<port> (CORS gates reading the response, not sending
4199
+ * the request); so can any local process. An unbounded body lets either
4200
+ * stream the proxy to death.
4201
+ * - Raising it above 128 MB buys nothing and costs safety: the proxy
4202
+ * BUFFERS the whole body (`c.req.json()`), parses it into a JS object
4203
+ * (multiples of the text size in heap), then re-serializes it upstream.
4204
+ * Past ~512 MB V8's max string length makes `JSON.stringify` fail
4205
+ * opaquely — a cap well under that keeps the failure explainable.
4206
+ *
4207
+ * A body over this ceiling means something is wrong (runaway loop,
4208
+ * corrupted context), not a workload that needs a bigger buffer, so
4209
+ * there is deliberately no env override to raise it.
4210
+ */
4211
+ const MAX_REQUEST_BODY_BYTES = 134217728;
4212
+ /**
4213
+ * Where the RUNTIME's own body check sits. Not the policy.
4214
+ *
4215
+ * The policy is `MAX_REQUEST_BODY_BYTES`, enforced by `withBodyLimit` in
4216
+ * this process, identically on every runtime. This value exists only to
4217
+ * displace the 128 MB default bun's `serve` applies, which would otherwise pre-empt
4218
+ * that gate — bun enforces its limit at header-parse time by replying and
4219
+ * closing while the client is still uploading, so a client that has not
4220
+ * finished writing never reads the reply. Measured with a real client
4221
+ * posting 64 MB over a limit: `The socket connection was closed
4222
+ * unexpectedly`, i.e. exactly the opaque failure this change removes, and
4223
+ * exactly what bun does TODAY at its default. An early response from our
4224
+ * own handler, by contrast, is delivered cleanly (413 with the JSON body,
4225
+ * ~50ms, no memory growth; draining the body first was strictly worse at
4226
+ * 155ms and +50 MB, so we do not drain).
4227
+ *
4228
+ * Set as ONE top-level srvx option, so bun and node get the same number.
4229
+ * A bun-only override would put the two runtimes back on different
4230
+ * thresholds, which is the defect being fixed, not a fix for it.
4231
+ *
4232
+ * Deliberately far above the policy so the policy always decides first:
4233
+ * everything a client can realistically send is answered by the explained
4234
+ * 413, and this is reached only by a body that is both undeclared and
4235
+ * absurd. It stays finite so "no limit at all" is never the state. It
4236
+ * bounds nothing the gate does not already bound — an unread body is
4237
+ * discarded by both runtimes rather than buffered — so a high value costs
4238
+ * no memory, and buys message quality across the whole realistic range.
4239
+ */
4240
+ const TRANSPORT_BODY_CEILING_BYTES = 1073741824;
4241
+ /**
4242
+ * The 413 the user sees when a request body is over the ceiling.
4243
+ *
4244
+ * Anthropic error envelope (`request_too_large` is Anthropic's own 413
4245
+ * category), so a client that parses our error shape on every other path
4246
+ * parses this one too, and the message says what to actually do about it.
4247
+ *
4248
+ * `declaredBytes` is present only when the request declared a length. A
4249
+ * body caught mid-stream has no known size, and saying so beats inventing
4250
+ * one. Byte counts are exact, with the MB figure only as a gloss:
4251
+ * rounding alone reads as a contradiction at the boundary, where a body
4252
+ * one byte over renders as "128.0 MB, over the 128.0 MB limit".
4253
+ */
4254
+ function bodyTooLargeResponse(declaredBytes) {
4255
+ const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;
4256
+ const actual = declaredBytes === void 0 ? `Request body exceeds the size github-router accepts` : `Request body is ${declaredBytes} bytes (${mb(declaredBytes)}), over the size github-router accepts`;
4257
+ return new Response(JSON.stringify({
4258
+ type: "error",
4259
+ error: {
4260
+ type: "request_too_large",
4261
+ message: `${actual}: at most ${MAX_REQUEST_BODY_BYTES} bytes (${mb(MAX_REQUEST_BODY_BYTES)}), the same limit under bun and node. This is usually an accumulated conversation or inline images (base64 attachments are re-sent every turn) rather than one large message — start a new session or drop the attachments.`
4262
+ }
4263
+ }), {
4264
+ status: 413,
4265
+ headers: { "content-type": "application/json" }
4266
+ });
4267
+ }
4268
+ /** srvx's canonical over-limit error, however it reaches us. */
4269
+ function isBodyTooLarge(error) {
4270
+ return typeof error === "object" && error !== null && error.code === "ERR_BODY_TOO_LARGE";
4271
+ }
4272
+ /**
4273
+ * Enforce `MAX_REQUEST_BODY_BYTES` in-process, so the limit and the
4274
+ * rejection are the same on every runtime.
4275
+ *
4276
+ * Leaving it to the runtimes is what produced the divergence in the first
4277
+ * place, and their rejections are not interchangeable:
4278
+ *
4279
+ * - bun rejects at header-parse time and closes the connection while the
4280
+ * client is still uploading, so the client sees a dead socket rather
4281
+ * than the 413 (measured; see `TRANSPORT_BODY_CEILING_BYTES`).
4282
+ * - node lets the handler run and rejects the body READ with an
4283
+ * `ERR_BODY_TOO_LARGE` error. The app installs no Hono `onError`, so
4284
+ * that becomes `500 Internal Server Error` — indistinguishable from a
4285
+ * real proxy fault.
4286
+ *
4287
+ * Two layers, one threshold, applied uniformly:
4288
+ *
4289
+ * 1. A declared `Content-Length` over the limit is answered before the
4290
+ * body is read at all. This is the path every real client takes
4291
+ * (Claude Code / undici / curl all declare a length on a JSON body).
4292
+ * 2. Anything else — chunked, or a length that understates the body — is
4293
+ * caught mid-stream by srvx's own `limitRequestBody`, the same helper
4294
+ * its node and deno adapters use. Overflow is surfaced as the SAME
4295
+ * explained 413 rather than leaking out as a 500.
4296
+ *
4297
+ * So a body that declares no length is no longer a hole in the message
4298
+ * quality OR in the bound, and neither depends on which runtime is
4299
+ * serving. Verified end to end against both adapters with a real client:
4300
+ * declared-over and chunked-over each return an identical 413.
4301
+ */
4302
+ function withBodyLimit(fetchHandler) {
4303
+ return async (request) => {
4304
+ const declared = request.headers.get("content-length");
4305
+ if (declared !== null && /^\d+$/.test(declared)) {
4306
+ const bytes = Number(declared);
4307
+ if (bytes > 134217728) return bodyTooLargeResponse(bytes);
4308
+ }
4309
+ let overflowed = false;
4310
+ const limited = limitRequestBody(request, MAX_REQUEST_BODY_BYTES, { createError: (max) => {
4311
+ overflowed = true;
4312
+ return createBodyTooLargeError(max);
4313
+ } });
4314
+ try {
4315
+ const response = await fetchHandler(limited);
4316
+ if (overflowed) {
4317
+ response.body?.cancel().catch(() => {});
4318
+ return bodyTooLargeResponse();
4319
+ }
4320
+ return response;
4321
+ } catch (error) {
4322
+ if (overflowed || isBodyTooLarge(error)) return bodyTooLargeResponse();
4323
+ throw error;
4324
+ }
4325
+ };
4326
+ }
4327
+ /**
4328
+ * Build the srvx `serve()` options shared by the explicit-port and
4329
+ * random-port paths.
4330
+ *
4331
+ * Extracted and exported so the per-runtime overrides below are
4332
+ * assertable. `setupAndServe` itself performs auth and network I/O, so the
4333
+ * only way to pin this behaviour in a test is to make the options object
4334
+ * reachable on its own — and it needs pinning, because deleting either
4335
+ * override reintroduces a failure that looks like an upstream/network
4336
+ * problem rather than a config one.
4337
+ */
4338
+ function buildServeOptions(fetchHandler, silent) {
4339
+ return {
4340
+ fetch: withBodyLimit(fetchHandler),
4341
+ hostname: "127.0.0.1",
4342
+ silent,
4343
+ maxRequestBodySize: TRANSPORT_BODY_CEILING_BYTES,
4344
+ bun: { idleTimeout: 0 }
4345
+ };
4346
+ }
4347
+ async function setupAndServe(options) {
4348
+ if (options.proxyEnv) initProxyFromEnv();
4349
+ if (options.verbose) {
4350
+ consola.level = 5;
4351
+ consola.info("Verbose logging enabled");
4352
+ }
4353
+ state.accountType = options.accountType;
4354
+ if (options.accountType !== "individual") consola.info(`Using ${options.accountType} plan GitHub account`);
4355
+ state.manualApprove = options.manual;
4356
+ state.rateLimitSeconds = options.rateLimit;
4357
+ state.rateLimitWait = options.rateLimitWait;
4358
+ state.showToken = options.showToken;
4359
+ state.extendedBetas = options.extendedBetas;
4360
+ state.browseEnabled = options.browseEnabled || process.env.GH_ROUTER_ENABLE_BROWSE === "1";
4361
+ state.fleetEnabled = options.fleetEnabled || process.env.GH_ROUTER_ENABLE_FLEET === "1";
4362
+ state.agentsEnabled = options.agentsEnabled || process.env.GH_ROUTER_ENABLE_AGENTS === "1";
4363
+ state.powerBrowseEnabled = options.powerBrowseEnabled || process.env.GH_ROUTER_ENABLE_POWER_BROWSE === "1";
4364
+ if (state.powerBrowseEnabled) state.browseEnabled = true;
4365
+ if (process.env.GH_ROUTER_BROWSER_NO_HUMANLIKE === "1") state.humanlikeForce = "off";
4366
+ else if (options.humanlikeEnabled || process.env.GH_ROUTER_HUMANLIKE === "1") state.humanlikeForce = "on";
4367
+ else state.humanlikeForce = "auto";
4368
+ if (process.env.COPILOT_API_URL) state.copilotApiUrl = process.env.COPILOT_API_URL;
4369
+ await ensurePaths();
4370
+ await Promise.all([cacheVSCodeVersion(), cacheCopilotVersion()]);
4371
+ if (options.githubToken) {
4372
+ state.githubToken = options.githubToken;
4373
+ consola.info("Using provided GitHub token");
4374
+ } else await setupGitHubToken();
4375
+ if (state.agentsEnabled) await setupGitHubAgentToken();
4376
+ await setupCopilotToken();
4377
+ await cacheModels();
4378
+ consola.debug(`Available models: \n${state.models?.data.map((model) => `- ${model.id}`).join("\n")}`);
4379
+ const serveOptions = buildServeOptions(server.fetch, options.silent);
4380
+ let srvxServer;
4381
+ if (options.port !== void 0) srvxServer = serve({
4382
+ ...serveOptions,
4383
+ port: options.port
4384
+ });
4385
+ else {
4386
+ let lastError;
4387
+ for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
4388
+ const candidatePort = generateRandomPort();
4389
+ try {
4390
+ srvxServer = serve({
4391
+ ...serveOptions,
4392
+ port: candidatePort
4393
+ });
4394
+ break;
4395
+ } catch (error) {
4396
+ lastError = error;
4397
+ if (!(error instanceof Error && (error.message.includes("EADDRINUSE") || error.message.includes("address already in use") || "code" in error && error.code === "EADDRINUSE"))) throw error;
4398
+ consola.debug(`Port ${candidatePort} in use, trying another...`);
4399
+ }
4400
+ }
4401
+ if (srvxServer === void 0) throw new Error(`Failed to find an available port after ${MAX_PORT_RETRIES} attempts. Specify a port with --port or free some ports. Last error: ${lastError}`);
4402
+ }
4403
+ await srvxServer.ready();
4404
+ const url = srvxServer.url;
4405
+ if (!url) throw new Error("Server started but URL is not available");
4406
+ const serverUrl = url.replace(/\/$/, "");
4407
+ try {
4408
+ const handle = maybeSpawnDaemon({ agentsEnabled: agentToolsEnabled() });
4409
+ if (handle) {
4410
+ wireDaemonTeardown(handle);
4411
+ consola.debug(`first-mate daemon spawn attempted (pid ${handle.pid ?? "?"}).`);
4412
+ }
4413
+ } catch (err) {
4414
+ consola.debug("first-mate daemon auto-spawn skipped:", err);
4415
+ }
4416
+ return {
4417
+ server: srvxServer,
4418
+ serverUrl
4419
+ };
4420
+ }
4421
+ /** Shared CLI arg definitions for all server commands. */
4422
+ const sharedServerArgs = {
4423
+ port: {
4424
+ alias: "p",
4425
+ type: "string",
4426
+ description: "Port to listen on"
4427
+ },
4428
+ verbose: {
4429
+ alias: "v",
4430
+ type: "boolean",
4431
+ default: false,
4432
+ description: "Enable verbose logging"
4433
+ },
4434
+ "account-type": {
4435
+ alias: "a",
4436
+ type: "string",
4437
+ default: "enterprise",
4438
+ description: "Account type to use (individual, business, enterprise)"
4439
+ },
4440
+ manual: {
4441
+ type: "boolean",
4442
+ default: false,
4443
+ description: "Enable manual request approval"
4444
+ },
4445
+ "rate-limit": {
4446
+ alias: "r",
4447
+ type: "string",
4448
+ description: "Rate limit in seconds between requests"
4449
+ },
4450
+ wait: {
4451
+ alias: "w",
4452
+ type: "boolean",
4453
+ default: false,
4454
+ description: "Wait instead of error when rate limit is hit. Has no effect if rate limit is not set"
4455
+ },
4456
+ "github-token": {
4457
+ alias: "g",
4458
+ type: "string",
4459
+ description: "Provide GitHub token directly (must be generated using the `auth` subcommand)"
4460
+ },
4461
+ "show-token": {
4462
+ type: "boolean",
4463
+ default: false,
4464
+ description: "Show GitHub and Copilot tokens on fetch and refresh"
4465
+ },
4466
+ "proxy-env": {
4467
+ type: "boolean",
4468
+ default: false,
4469
+ description: "Initialize proxy from environment variables"
4470
+ },
4471
+ "extended-betas": {
4472
+ type: "boolean",
4473
+ default: false,
4474
+ description: "Forward extended beta headers for Claude CLI compatibility (default: VS Code-only)"
4475
+ },
4476
+ browse: {
4477
+ type: "boolean",
4478
+ default: false,
4479
+ description: "Enable the browser-control MCP tools (browser_open_tab, browser_screenshot, browser_click, etc.) on /mcp. Requires Chrome or Edge installed; the bundled extension must be loaded on first tool call (the proxy returns install_required with a Load Unpacked path and the expected extension ID). Off by default; can also be enabled with GH_ROUTER_ENABLE_BROWSE=1."
4480
+ },
4481
+ fleet: {
4482
+ type: "boolean",
4483
+ default: false,
4484
+ description: "Enable the fleet session-control MCP tools (mcp__fleet__*) on /mcp for driving sessions across remote ai-or-die instances over their tunnels. Off by default; also enabled with GH_ROUTER_ENABLE_FLEET=1."
4485
+ },
4486
+ agents: {
4487
+ type: "boolean",
4488
+ default: false,
4489
+ description: "Enable the first-mate cloud-agent orchestration surface (mcp__first-mate__*): a durable controller that drives GitHub cloud coding agents (Copilot/Anthropic/OpenAI) across research/plan/implement/test/merge. Triggers a second GitHub device-login for a write-capable token (repo, workflow, read:org). Off by default; also enabled with GH_ROUTER_ENABLE_AGENTS=1."
4490
+ },
4491
+ "power-browse": {
4492
+ type: "boolean",
4493
+ default: false,
4494
+ description: "Expose the full ~18-tool browser MCP surface (raw read_page, mouse / drag / scroll / keyboard / type primitives, eval_js, diagnostics, find, locate). Default --browse exposes only the 6 lead-model tools (act, observe, extract, navigate, screenshot, open_tab) that hide DOM details behind intent. Implies --browse. Off by default; can also be enabled with GH_ROUTER_ENABLE_POWER_BROWSE=1."
4495
+ },
4496
+ humanlike: {
4497
+ type: "boolean",
4498
+ default: false,
4499
+ description: "Force humanlike pacing on ALL browser tool dispatches: Beta-distributed inter-action delays (800-4600 ms), Bezier mouse trajectories with overshoot-and-correct, per-keystroke jitter with word-end pauses, scroll chunking. Use for known anti-bot sites (Cloudflare, Datadome). Off by default (auto mode); GH_ROUTER_HUMANLIKE=1 is the env equivalent. GH_ROUTER_BROWSER_NO_HUMANLIKE=1 hard-disables (wins over --humanlike, for tests)."
4500
+ },
4501
+ "self-update": {
4502
+ type: "boolean",
4503
+ default: true,
4504
+ description: "Update github-router itself to the latest npm version on launch (throttled once/hour). Best-effort and non-blocking: the proxy serves immediately and a detached updater applies the new version after this process exits (it takes effect on the NEXT launch; the running process keeps its current build). Disable with --no-self-update or GH_ROUTER_NO_SELF_UPDATE=1. Skipped silently if npm/network unavailable."
4505
+ }
4506
+ };
4507
+ const allowedAccountTypes = /* @__PURE__ */ new Set([
4508
+ "individual",
4509
+ "business",
4510
+ "enterprise"
4511
+ ]);
4512
+ /** Parse shared server args into ServerSetupOptions fields. */
4513
+ function parseSharedArgs(args) {
4514
+ const portRaw = args.port;
4515
+ let port;
4516
+ if (portRaw !== void 0) {
4517
+ port = Number.parseInt(portRaw, 10);
4518
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error("Invalid port. Must be between 1 and 65535.");
4519
+ }
4520
+ const accountType = args["account-type"] ?? "enterprise";
4521
+ if (!allowedAccountTypes.has(accountType)) throw new Error("Invalid account type. Must be individual, business, or enterprise.");
4522
+ const rateLimitRaw = args["rate-limit"];
4523
+ let rateLimit;
4524
+ if (rateLimitRaw !== void 0) {
4525
+ rateLimit = Number.parseInt(rateLimitRaw, 10);
4526
+ if (Number.isNaN(rateLimit) || rateLimit <= 0) throw new Error("Invalid rate limit. Must be a positive integer.");
4527
+ }
4528
+ const rateLimitWait = args.wait && rateLimit !== void 0;
4529
+ if (args.wait && rateLimit === void 0) consola.warn("Rate limit wait ignored because no rate limit was set.");
4530
+ const githubToken = args["github-token"] ?? process.env.GH_TOKEN;
4531
+ return {
4532
+ port,
4533
+ verbose: args.verbose,
4534
+ accountType,
4535
+ manual: args.manual,
4536
+ rateLimit,
4537
+ rateLimitWait,
4538
+ githubToken,
4539
+ showToken: args["show-token"],
4540
+ proxyEnv: args["proxy-env"],
4541
+ extendedBetas: args["extended-betas"],
4542
+ browseEnabled: args.browse,
4543
+ fleetEnabled: args.fleet,
4544
+ agentsEnabled: args.agents,
4545
+ powerBrowseEnabled: args["power-browse"],
4546
+ humanlikeEnabled: args.humanlike
4547
+ };
4548
+ }
4549
+ /**
4550
+ * Non-Claude models we surface as first-class, selectable rows in Claude
4551
+ * Code's model picker (Phase 3 of native-non-claude-models). The main
4552
+ * agent loop runs on them through the `/v1/messages` translation shim
4553
+ * (`src/lib/anthropic-translate/*`, branched in `routes/messages/handler.ts`)
4554
+ * that forwards non-Claude targets to Copilot `/responses` (gpt) or
4555
+ * `/chat/completions` (gemini). The exact gemini id is
4556
+ * `gemini-3.1-pro-preview` (preview slug — NOT `gemini-3.1-pro`).
4557
+ *
4558
+ * Display labels only: the gateway-model cache schema Claude Code reads is
4559
+ * `{id, display_name?}` per model — there is NO per-model context-window
4560
+ * field, so context accounting for a selected row uses Claude Code's
4561
+ * default window (safe under-accounting: it compacts earlier than the real
4562
+ * 1M/400k window, never overflows). See `seedGatewayModelCache`.
4563
+ */
4564
+ const NATIVE_NON_CLAUDE_MODELS = [
4565
+ {
4566
+ id: "gpt-5.6-sol",
4567
+ displayName: "GPT-5.6 Sol"
4568
+ },
4569
+ {
4570
+ id: "gpt-5.5",
4571
+ displayName: "GPT-5.5"
4572
+ },
4573
+ {
4574
+ id: "gpt-5.3-codex",
4575
+ displayName: "GPT-5.3 Codex"
4576
+ },
4577
+ {
4578
+ id: "gemini-3.5-flash",
4579
+ displayName: "Gemini 3.5 Flash"
4580
+ },
4581
+ {
4582
+ id: "gemini-3.1-pro-preview",
4583
+ displayName: "Gemini 3.1 Pro (preview)"
4584
+ }
4585
+ ];
4586
+ /**
4587
+ * The subset of `NATIVE_NON_CLAUDE_MODELS` actually present in the live
4588
+ * Copilot catalog. License tiers differ (gpt-5.5 needs
4589
+ * pro_plus/business/enterprise/max; gemini-3.5-flash is absent on
4590
+ * edu/individual_trial), so a model missing from the catalog is silently
4591
+ * dropped — the caller then neither enables discovery nor writes a cache
4592
+ * for it, and lesser tiers see the unchanged picker. Pure (reads
4593
+ * `state.models`), so it is unit-testable without side effects.
4594
+ */
4595
+ function nativeSelectableModelsInCatalog() {
4596
+ const catalog = state.models?.data;
4597
+ if (!catalog || catalog.length === 0) return [];
4598
+ const present = new Set(catalog.map((m) => m.id));
4599
+ return NATIVE_NON_CLAUDE_MODELS.filter((m) => present.has(m.id)).map((m) => ({
4600
+ id: m.id,
4601
+ display_name: m.displayName
4602
+ }));
4603
+ }
4604
+ /**
4605
+ * Pre-seed Claude Code's gateway-model discovery cache so the non-Claude
4606
+ * models appear as selectable picker rows WITHOUT the network fetch.
4607
+ *
4608
+ * Verified against the installed Claude Code build (2.1.201): the picker
4609
+ * builder reads `<CLAUDE_CONFIG_DIR>/cache/gateway-models.json`
4610
+ * (schema `{baseUrl: string, fetchedAt: number, models: [{id, display_name?}]}`)
4611
+ * and — when gateway discovery is enabled (first-party auth mode +
4612
+ * `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` set + a non-`api.anthropic.com`
4613
+ * `ANTHROPIC_BASE_URL`, all true for the proxy) — maps each cached model to
4614
+ * a picker row `{value: id, label: display_name}`. Critically, the
4615
+ * cache-READ path applies NO id filter; the `/^(claude|anthropic)/i` filter
4616
+ * lives ONLY in the network-FETCH path. So a pre-seeded cache can carry the
4617
+ * real Copilot ids (`gpt-5.5`, `gemini-3.1-pro-preview`, …) — no `claude-*`
4618
+ * alias needed — and selecting a row sends that real id, which
4619
+ * `resolveModel()` exact-matches and the `/v1/messages` shim routes.
4620
+ *
4621
+ * The network fetch never overwrites this seed: it bails when nonessential
4622
+ * traffic is disabled, and the proxy ALWAYS sets
4623
+ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, so the fetch returns before
4624
+ * it can write. The seed is therefore authoritative for the session.
4625
+ *
4626
+ * `baseUrl` MUST equal the `ANTHROPIC_BASE_URL` Claude Code sees
4627
+ * (`serverUrl`) or the cache is discarded. `configDir` defaults to
4628
+ * `PATHS.CLAUDE_CONFIG_DIR` — the same dir the proxy points
4629
+ * `CLAUDE_CONFIG_DIR` at — so the write target and Claude Code's read
4630
+ * target are identical by construction.
4631
+ *
4632
+ * Best-effort: every failure is swallowed — a missing picker row must never
4633
+ * break launch. This is coupled to Claude Code's internal cache path/schema;
4634
+ * if a future build changes them the read simply ignores the seed and the
4635
+ * rows don't appear (graceful degradation). Returns whether a file was
4636
+ * written (for tests/observability).
4637
+ *
4638
+ * The write is atomic (temp file in the same dir + rename) so a Claude Code
4639
+ * read can never observe a torn/partial JSON (which its safeParse would
4640
+ * reject, dropping the rows). Rename-over-existing is atomic on POSIX and
4641
+ * Windows (libuv MoveFileEx REPLACE_EXISTING).
4642
+ */
4643
+ function seedGatewayModelCache(serverUrl, models, configDir = PATHS.CLAUDE_CONFIG_DIR) {
4644
+ if (models.length === 0) return false;
4645
+ const cacheDir = nodePath.join(configDir, "cache");
4646
+ const target = nodePath.join(cacheDir, "gateway-models.json");
4647
+ const tmp = nodePath.join(cacheDir, `gateway-models.json.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
4648
+ try {
4649
+ fs$2.mkdirSync(cacheDir, { recursive: true });
4650
+ const payload = {
4651
+ baseUrl: serverUrl,
4652
+ fetchedAt: Date.now(),
4653
+ models: models.map((m) => ({
4654
+ id: m.id,
4655
+ display_name: m.display_name
4656
+ }))
4657
+ };
4658
+ fs$2.writeFileSync(tmp, JSON.stringify(payload), "utf-8");
4659
+ fs$2.renameSync(tmp, target);
4660
+ return true;
4661
+ } catch {
4662
+ try {
4663
+ fs$2.rmSync(tmp, { force: true });
4664
+ } catch {}
4665
+ return false;
4666
+ }
4667
+ }
4668
+ /**
4669
+ * Remove any seeded gateway-model cache. Called when the current catalog
4670
+ * carries none of the target models, so a user who has pinned
4671
+ * `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` on cannot surface rows for
4672
+ * models that are no longer available. Best-effort (per-launch config dirs
4673
+ * make a stale file rare, but this closes the pinned-port + catalog-change
4674
+ * seam). Never throws.
4675
+ */
4676
+ function clearGatewayModelCache(configDir = PATHS.CLAUDE_CONFIG_DIR) {
4677
+ try {
4678
+ fs$2.rmSync(nodePath.join(configDir, "cache", "gateway-models.json"), { force: true });
4679
+ } catch {}
4680
+ }
4681
+ /**
4682
+ * Build environment variables for Claude Code.
4683
+ *
4684
+ * The parent env is sanitized of every key in `STRIPPED_PARENT_ENV_KEYS`
4685
+ * (see `src/lib/launch.ts`) BEFORE these overrides are merged in, so we
4686
+ * only need to provide the positive values.
4687
+ *
4688
+ * Auth precedence in Claude Code (https://code.claude.com/docs/en/iam),
4689
+ * after the github-router substrate fix:
4690
+ * 1. Cloud provider (CLAUDE_CODE_USE_BEDROCK / VERTEX / FOUNDRY) — stripped at parent.
4691
+ * 2. ANTHROPIC_AUTH_TOKEN — NOT set by the proxy. Stripped at parent
4692
+ * (no env-source auth in the spawned child at all).
4693
+ * 3. ANTHROPIC_API_KEY — stripped at parent.
4694
+ * 4. apiKeyHelper in settings.json — copied into our config dir as
4695
+ * part of the mirror; if the user defined one, it still fires
4696
+ * and may mint an `x-api-key` header. Copilot ignores `x-api-key`,
4697
+ * so behavior is unchanged from before this fix.
4698
+ * 5. CLAUDE_CODE_OAUTH_TOKEN — stripped at parent.
4699
+ * 6. Subscription OAuth (Keychain / `<CLAUDE_CONFIG_DIR>/.credentials.json`)
4700
+ * — the credentials file is OURS (synthetic blob, written by
4701
+ * `ensureClaudeConfigMirror`). Claude Code reads accessToken from
4702
+ * it and sends as `Authorization: Bearer <accessToken>`. The
4703
+ * teammate-spawn allowlist propagates `CLAUDE_CONFIG_DIR` to
4704
+ * children, so spawned teammates find the same synthetic credential
4705
+ * and authenticate (the bug this whole fix addresses).
4706
+ *
4707
+ * `CLAUDE_CONFIG_DIR` activates Claude Code's per-config-dir keychain
4708
+ * isolation (per binary-grep of v2.1.126's `iN()` function: when set,
4709
+ * the keychain service name becomes `Claude Code-<sha256(path)[0..8]>`,
4710
+ * missing the user's real `Claude Code` entry). Pointing it at our
4711
+ * snapshot-copied `PATHS.CLAUDE_CONFIG_DIR` preserves user customization
4712
+ * (mirrored settings.json, skills, MCP, hooks, CLAUDE.md, custom
4713
+ * agents) while giving teammates a credential they can find on disk.
4714
+ *
4715
+ * No-401 invariant: Claude Code's reactive refresh path (`SZ1` →
4716
+ * `D3(0,true,...)`) fires on any 401 from upstream. The synthetic
4717
+ * refreshToken would fail any real refresh attempt, so the proxy
4718
+ * MUST NOT return 401 on the Anthropic-shape boundary even when
4719
+ * upstream Copilot returns 401. See `src/routes/messages/handler.ts`.
4720
+ */
4721
+ function getClaudeCodeEnvVars(serverUrl, model) {
4722
+ const vars = {
4723
+ ANTHROPIC_BASE_URL: serverUrl,
4724
+ CLAUDE_CONFIG_DIR: PATHS.CLAUDE_CONFIG_DIR,
4725
+ DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
4726
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
4727
+ DISABLE_TELEMETRY: "1"
4728
+ };
4729
+ if (model) vars.ANTHROPIC_MODEL = model;
4730
+ const mcpToolTimeoutMs = String(resolveMcpToolTimeoutMs());
4731
+ if (process.env.MCP_TIMEOUT === void 0) vars.MCP_TIMEOUT = mcpToolTimeoutMs;
4732
+ if (process.env.MCP_TOOL_TIMEOUT === void 0) vars.MCP_TOOL_TIMEOUT = mcpToolTimeoutMs;
4733
+ if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-sonnet-5";
4734
+ if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL === void 0) vars.ANTHROPIC_DEFAULT_SONNET_MODEL = "claude-sonnet-5";
4735
+ if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === void 0) vars.ANTHROPIC_DEFAULT_HAIKU_MODEL = "claude-sonnet-5";
4736
+ if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL === void 0) vars.ANTHROPIC_DEFAULT_OPUS_MODEL = "claude-opus-5";
4737
+ if (process.env.CLAUDE_CODE_PLAN_V2_AGENT_COUNT === void 0) vars.CLAUDE_CODE_PLAN_V2_AGENT_COUNT = "7";
4738
+ for (const key of [
4739
+ "CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL",
4740
+ "CLAUDE_CODE_FORK_SUBAGENT",
4741
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
4742
+ "CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING",
4743
+ "CLAUDE_CODE_ENABLE_TASKS"
4744
+ ]) if (process.env[key] === void 0) vars[key] = "1";
4745
+ const nativeModels = nativeSelectableModelsInCatalog();
4746
+ if (nativeModels.length > 0) {
4747
+ if (seedGatewayModelCache(serverUrl, nativeModels) && process.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY === void 0 && vars.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY === void 0) vars.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
4748
+ } else clearGatewayModelCache();
4749
+ if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
4750
+ return vars;
4751
+ }
4752
+ /**
4753
+ * Build environment variables for Codex CLI.
4754
+ *
4755
+ * Like `getClaudeCodeEnvVars`, the parent env is sanitized of
4756
+ * `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `CODEX_HOME` (see
4757
+ * `STRIPPED_PARENT_ENV_KEYS` in `src/lib/launch.ts`) before these
4758
+ * overrides are merged, so a stale shell `OPENAI_API_KEY` can't leak
4759
+ * through. Codex caches a ChatGPT subscription login under
4760
+ * `$CODEX_HOME/auth.json` which can override `OPENAI_API_KEY` per
4761
+ * openai/codex#2733; pointing `CODEX_HOME` at an isolated directory
4762
+ * masks any cached login.
4763
+ */
4764
+ function getCodexEnvVars(serverUrl) {
4765
+ const vars = {
4766
+ OPENAI_BASE_URL: `${serverUrl}/v1`,
4767
+ OPENAI_API_KEY: "dummy",
4768
+ CODEX_HOME: PATHS.CODEX_HOME
4769
+ };
4770
+ if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
4771
+ return vars;
4772
+ }
4773
+ //#endregion
4774
+ export { sharedServerArgs as a, listModelsForEndpoint as c, updateClaude as d, setupAndServe as i, runSelfUpdate as l, getCodexEnvVars as n, startKeepAwake as o, parseSharedArgs as r, stopKeepAwake as s, getClaudeCodeEnvVars as t, checkClaudeVersion as u };
4775
+
4776
+ //# sourceMappingURL=server-setup-DcO9qvN3.js.map