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.
- package/dist/attribution-settings-CegtE0TY.js +2347 -0
- package/dist/attribution-settings-CegtE0TY.js.map +1 -0
- package/dist/auth-CP4q2CjO.js +44 -0
- package/dist/auth-CP4q2CjO.js.map +1 -0
- package/dist/browser-ext/background.js +8 -1
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/check-usage-6PB5IUFb.js +43 -0
- package/dist/check-usage-6PB5IUFb.js.map +1 -0
- package/dist/claude-BiZxoNWq.js +593 -0
- package/dist/claude-BiZxoNWq.js.map +1 -0
- package/dist/client-CQMfroGV.js +413 -0
- package/dist/client-CQMfroGV.js.map +1 -0
- package/dist/codex-ST21X3WF.js +85 -0
- package/dist/codex-ST21X3WF.js.map +1 -0
- package/dist/debug-CjmE9sgG.js +81 -0
- package/dist/debug-CjmE9sgG.js.map +1 -0
- package/dist/engine-DNCX0Hfd.js +2 -0
- package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
- package/dist/exec-DTO2oin0.js.map +1 -0
- package/dist/file-log-reporter-IFQcL5IK.js +158 -0
- package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
- package/dist/gate-discovery-CepmbZnc.js +635 -0
- package/dist/gate-discovery-CepmbZnc.js.map +1 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
- package/dist/hook-mcp-client-DBvm9608.js +136 -0
- package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
- package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
- package/dist/internal-plan-review-ipnT3bhj.js +114 -0
- package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
- package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
- package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
- package/dist/internal-session-bind-DwK2iGW0.js +133 -0
- package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
- package/dist/internal-stop-hook-CNPGJm63.js +212 -0
- package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
- package/dist/internal-stop-review-AUcFssrW.js +122 -0
- package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
- package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
- package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
- package/dist/lifecycle-Byw5s1G8.js +134 -0
- package/dist/lifecycle-Byw5s1G8.js.map +1 -0
- package/dist/lifecycle-D80CaWie.js +2 -0
- package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
- package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
- package/dist/lifecycle-DzqCqDlM.js +2 -0
- package/dist/main.js +43 -12884
- package/dist/main.js.map +1 -1
- package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
- package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
- package/dist/models-B6UnGyRo.js +158 -0
- package/dist/models-B6UnGyRo.js.map +1 -0
- package/dist/orchestration-hRU4TdWr.js +39 -0
- package/dist/orchestration-hRU4TdWr.js.map +1 -0
- package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
- package/dist/paths-C1cLZ7Ga.js.map +1 -0
- package/dist/paths-CwKwc2pi.js +2 -0
- package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
- package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
- package/dist/plan-review-hook-C8x0uKPf.js +159 -0
- package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
- package/dist/provision-DSkPa5hY.js +656 -0
- package/dist/provision-DSkPa5hY.js.map +1 -0
- package/dist/serve-sa_7c-yD.js +1511 -0
- package/dist/serve-sa_7c-yD.js.map +1 -0
- package/dist/server-setup-DcO9qvN3.js +4776 -0
- package/dist/server-setup-DcO9qvN3.js.map +1 -0
- package/dist/start-siGxuZhO.js +129 -0
- package/dist/start-siGxuZhO.js.map +1 -0
- package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
- package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
- package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
- package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
- package/dist/token-R2ocFC3M.js +1054 -0
- package/dist/token-R2ocFC3M.js.map +1 -0
- package/dist/version-_Q1WpsQp.js +32 -0
- package/dist/version-_Q1WpsQp.js.map +1 -0
- package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
- package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
- package/package.json +1 -1
- package/dist/engine-BuuHUb4t.js +0 -2
- package/dist/lifecycle-0rXrwjWk.js +0 -2
- package/dist/lifecycle-DHZsVN8j.js.map +0 -1
- package/dist/lifecycle-hzPwXc3I.js +0 -2
- package/dist/paths-BjTMI_xK.js.map +0 -1
- package/dist/paths-D3XgmTcQ.js +0 -2
- 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
|