github-router 0.3.19 → 0.3.20
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/main.js +1303 -23
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -6,7 +6,8 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
8
8
|
import process$1 from "node:process";
|
|
9
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
9
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
10
11
|
import fs$1 from "node:fs";
|
|
11
12
|
import { Writable } from "node:stream";
|
|
12
13
|
import { serve } from "srvx";
|
|
@@ -494,9 +495,22 @@ const VSCODE_BETA_PREFIXES = [
|
|
|
494
495
|
* to work with the Copilot API.
|
|
495
496
|
*
|
|
496
497
|
* Notably absent (Copilot 400s on these — verified live):
|
|
497
|
-
* context-1m-, skills-, files-api-, code-execution-, output-128k
|
|
498
|
+
* context-1m-, skills-, files-api-, code-execution-, output-128k-,
|
|
499
|
+
* advisor-tool- (see EXPLICITLY_STRIPPED_BETA_PREFIXES below).
|
|
498
500
|
* 1M context is unlocked by selecting `claude-opus-4.7-1m-internal`
|
|
499
501
|
* as the model id, not via a beta header.
|
|
502
|
+
*
|
|
503
|
+
* Empirical verification (2026-05-11 against api.enterprise.githubcopilot.com):
|
|
504
|
+
* task-budgets-2026-03-13 → 200 ACCEPTED (cost-ceiling leverage)
|
|
505
|
+
* token-efficient-tools-2026-03-28 → 200 ACCEPTED (per-tool token saving)
|
|
506
|
+
* summarize-connector-text-2026-03-13 → 200 (Anthropic-internal feature flag,
|
|
507
|
+
* won't fire for non-ant users; allowlisted defensively for ant edge case)
|
|
508
|
+
* afk-mode-2026-01-31 → 200 (Anthropic-internal feature flag)
|
|
509
|
+
* cli-internal-2026-02-09 → 200 (USER_TYPE=ant only)
|
|
510
|
+
* oauth-2025-04-20 → 200 (Files-API path; Files-API itself
|
|
511
|
+
* is not supportable via Copilot, but the header alone is harmless)
|
|
512
|
+
* prompt-caching-scope-2026-01-05 → 200 even with body cache_control.scope
|
|
513
|
+
* stripped (already covered by `prompt-caching-` prefix above)
|
|
500
514
|
*/
|
|
501
515
|
const EXTENDED_BETA_PREFIXES = [
|
|
502
516
|
...VSCODE_BETA_PREFIXES,
|
|
@@ -513,17 +527,39 @@ const EXTENDED_BETA_PREFIXES = [
|
|
|
513
527
|
"mcp-client-",
|
|
514
528
|
"mcp-servers-",
|
|
515
529
|
"redact-thinking-",
|
|
516
|
-
"web-search-"
|
|
530
|
+
"web-search-",
|
|
531
|
+
"task-budgets-",
|
|
532
|
+
"token-efficient-tools-",
|
|
533
|
+
"summarize-connector-text-",
|
|
534
|
+
"afk-mode-",
|
|
535
|
+
"cli-internal-",
|
|
536
|
+
"oauth-"
|
|
517
537
|
];
|
|
518
538
|
/**
|
|
539
|
+
* Beta prefixes the proxy explicitly STRIPS even from the extended
|
|
540
|
+
* allowlist (and even if a future leverage mode broadens the allowlist
|
|
541
|
+
* further). Defensive layer: today's allowlist-only filter would already
|
|
542
|
+
* drop these because they're not in any allowlist, but keeping an
|
|
543
|
+
* explicit deny-list catches future changes that broaden allow rules
|
|
544
|
+
* (e.g. a hypothetical pattern-based mode that lets `claude-*` through).
|
|
545
|
+
*
|
|
546
|
+
* Empirical (2026-05-11): Copilot returns HTTP 400
|
|
547
|
+
* `unsupported beta header(s): advisor-tool-2026-03-01`
|
|
548
|
+
* on every request that includes `advisor-tool-`. Stripping it is the
|
|
549
|
+
* difference between a working request (no ADVISOR semantics) and a
|
|
550
|
+
* fully-failed request. Document upstream limitation in CLAUDE.md.
|
|
551
|
+
*/
|
|
552
|
+
const EXPLICITLY_STRIPPED_BETA_PREFIXES = ["advisor-tool-"];
|
|
553
|
+
/**
|
|
519
554
|
* Filter an `anthropic-beta` header value, keeping only beta flags
|
|
520
|
-
* in the active whitelist
|
|
521
|
-
* is enabled, VS Code-only
|
|
522
|
-
* Returns the filtered comma-separated string,
|
|
555
|
+
* in the active whitelist AND not in the explicit-strip list.
|
|
556
|
+
* Uses extended prefixes when --extended-betas is enabled, VS Code-only
|
|
557
|
+
* prefixes otherwise. Returns the filtered comma-separated string,
|
|
558
|
+
* or undefined if nothing remains.
|
|
523
559
|
*/
|
|
524
560
|
function filterBetaHeader(value) {
|
|
525
561
|
const prefixes = state.extendedBetas ? EXTENDED_BETA_PREFIXES : VSCODE_BETA_PREFIXES;
|
|
526
|
-
return value.split(",").map((v) => v.trim()).filter((v) => v && prefixes.some((prefix) => v.startsWith(prefix))).join(",") || void 0;
|
|
562
|
+
return value.split(",").map((v) => v.trim()).filter((v) => v && prefixes.some((prefix) => v.startsWith(prefix)) && !EXPLICITLY_STRIPPED_BETA_PREFIXES.some((p) => v.startsWith(p))).join(",") || void 0;
|
|
527
563
|
}
|
|
528
564
|
/**
|
|
529
565
|
* Normalize a model ID for fuzzy comparison: lowercase, replace dots with
|
|
@@ -579,6 +615,20 @@ function resolveModel(modelId) {
|
|
|
579
615
|
return retried;
|
|
580
616
|
}
|
|
581
617
|
}
|
|
618
|
+
if (lower.startsWith("claude-")) {
|
|
619
|
+
const matchSonnet = /(?:^|-)sonnet(?:-|$)/.test(lower);
|
|
620
|
+
const matchHaiku = /(?:^|-)haiku(?:-|$)/.test(lower);
|
|
621
|
+
if (matchSonnet || matchHaiku) {
|
|
622
|
+
const family = matchSonnet ? "sonnet" : "haiku";
|
|
623
|
+
const familyMembers = models.filter((m) => (/* @__PURE__ */ new RegExp(`(?:^|-)${family}(?:-|$|\\.)`)).test(m.id));
|
|
624
|
+
if (familyMembers.length > 0) {
|
|
625
|
+
familyMembers.sort((a, b) => b.id.localeCompare(a.id, void 0, { numeric: true }));
|
|
626
|
+
const best = familyMembers[0].id;
|
|
627
|
+
consola.info(`Model "${modelId}" not in Copilot catalog; falling back to highest available "${best}" (legacy ${family} slug). Pin a current catalog id to silence.`);
|
|
628
|
+
return best;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
582
632
|
consola.warn(`Model "${modelId}" not found in Copilot model list. Available: ${models.map((m) => m.id).join(", ")}`);
|
|
583
633
|
return modelId;
|
|
584
634
|
}
|
|
@@ -835,6 +885,177 @@ const checkUsage = defineCommand({
|
|
|
835
885
|
}
|
|
836
886
|
});
|
|
837
887
|
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/lib/claude-version-check.ts
|
|
890
|
+
const execFileAsync = promisify(execFile);
|
|
891
|
+
const NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
892
|
+
const THROTTLE_HOURS = 1;
|
|
893
|
+
const NPM_VIEW_TIMEOUT_MS = 5e3;
|
|
894
|
+
const NPM_INSTALL_TIMEOUT_MS = 12e4;
|
|
895
|
+
/** Path to the throttle cache. Created on demand. */
|
|
896
|
+
function cacheFilePath() {
|
|
897
|
+
return path.join(os.homedir(), ".local", "share", "github-router", "last-update-check");
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Read the throttle cache. Returns null on missing/corrupt file —
|
|
901
|
+
* triggers a fresh check.
|
|
902
|
+
*/
|
|
903
|
+
async function readCache() {
|
|
904
|
+
try {
|
|
905
|
+
const raw = await fs.readFile(cacheFilePath(), "utf8");
|
|
906
|
+
const parsed = JSON.parse(raw);
|
|
907
|
+
if (typeof parsed.checkedAt !== "string" || parsed.installedVersion !== null && typeof parsed.installedVersion !== "string" || parsed.latestVersion !== null && typeof parsed.latestVersion !== "string") return null;
|
|
908
|
+
return parsed;
|
|
909
|
+
} catch {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function writeCache(cache) {
|
|
914
|
+
try {
|
|
915
|
+
await fs.mkdir(path.dirname(cacheFilePath()), { recursive: true });
|
|
916
|
+
await fs.writeFile(cacheFilePath(), JSON.stringify(cache), { mode: 384 });
|
|
917
|
+
} catch (err) {
|
|
918
|
+
consola.debug("Failed to write claude version-check cache:", err);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/** Check if it's been more than THROTTLE_HOURS since the last check. */
|
|
922
|
+
function shouldCheckNow(cache) {
|
|
923
|
+
if (!cache) return true;
|
|
924
|
+
const lastCheck = new Date(cache.checkedAt).getTime();
|
|
925
|
+
if (Number.isNaN(lastCheck)) return true;
|
|
926
|
+
return (Date.now() - lastCheck) / 1e3 / 3600 >= THROTTLE_HOURS;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Read the installed `claude` version. Returns null if claude is not
|
|
930
|
+
* on PATH or the version probe fails (e.g. older versions that don't
|
|
931
|
+
* support `--version` cleanly).
|
|
932
|
+
*/
|
|
933
|
+
function getInstalledVersion() {
|
|
934
|
+
try {
|
|
935
|
+
const match = execFileSync("claude", ["--version"], {
|
|
936
|
+
stdio: [
|
|
937
|
+
"ignore",
|
|
938
|
+
"pipe",
|
|
939
|
+
"ignore"
|
|
940
|
+
],
|
|
941
|
+
timeout: 3e3,
|
|
942
|
+
encoding: "utf8"
|
|
943
|
+
}).match(/^(\d+\.\d+\.\d+)/);
|
|
944
|
+
return match ? match[1] : null;
|
|
945
|
+
} catch {
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Fetch the latest version of @anthropic-ai/claude-code from the npm
|
|
951
|
+
* registry. Returns null on network failure / npm unavailable.
|
|
952
|
+
*/
|
|
953
|
+
async function getLatestVersion() {
|
|
954
|
+
try {
|
|
955
|
+
const { stdout } = await execFileAsync("npm", [
|
|
956
|
+
"view",
|
|
957
|
+
NPM_PACKAGE,
|
|
958
|
+
"version",
|
|
959
|
+
"--silent"
|
|
960
|
+
], { timeout: NPM_VIEW_TIMEOUT_MS });
|
|
961
|
+
const v = stdout.trim();
|
|
962
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
963
|
+
} catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Compare two semver-shaped strings (only the leading X.Y.Z, no
|
|
969
|
+
* pre-release / metadata handling — sufficient for npm-published
|
|
970
|
+
* stable releases). Returns true if `latest` is strictly higher than
|
|
971
|
+
* `installed`.
|
|
972
|
+
*/
|
|
973
|
+
function isNewer(installed, latest) {
|
|
974
|
+
if (!installed || !latest) return false;
|
|
975
|
+
const a = installed.split(".").map((n) => parseInt(n, 10));
|
|
976
|
+
const b = latest.split(".").map((n) => parseInt(n, 10));
|
|
977
|
+
for (let i = 0; i < 3; i++) {
|
|
978
|
+
const av = a[i] ?? 0;
|
|
979
|
+
const bv = b[i] ?? 0;
|
|
980
|
+
if (av < bv) return true;
|
|
981
|
+
if (av > bv) return false;
|
|
982
|
+
}
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Run a version check (subject to throttle). Side-effect: updates the
|
|
987
|
+
* throttle cache. Returns the comparison result.
|
|
988
|
+
*/
|
|
989
|
+
async function checkClaudeVersion(opts = {}) {
|
|
990
|
+
if (opts.noCheck) return {
|
|
991
|
+
installed: false,
|
|
992
|
+
installedVersion: null,
|
|
993
|
+
latestVersion: null,
|
|
994
|
+
needsUpdate: false,
|
|
995
|
+
skipped: true,
|
|
996
|
+
skipReason: "disabled"
|
|
997
|
+
};
|
|
998
|
+
const cache = await readCache();
|
|
999
|
+
if (!opts.force && !shouldCheckNow(cache)) return {
|
|
1000
|
+
installed: cache?.installedVersion !== null,
|
|
1001
|
+
installedVersion: cache?.installedVersion ?? null,
|
|
1002
|
+
latestVersion: cache?.latestVersion ?? null,
|
|
1003
|
+
needsUpdate: isNewer(cache?.installedVersion ?? null, cache?.latestVersion ?? null),
|
|
1004
|
+
skipped: true,
|
|
1005
|
+
skipReason: "throttled"
|
|
1006
|
+
};
|
|
1007
|
+
const installedVersion = getInstalledVersion();
|
|
1008
|
+
if (installedVersion === null) return {
|
|
1009
|
+
installed: false,
|
|
1010
|
+
installedVersion: null,
|
|
1011
|
+
latestVersion: null,
|
|
1012
|
+
needsUpdate: false,
|
|
1013
|
+
skipped: true,
|
|
1014
|
+
skipReason: "no-claude"
|
|
1015
|
+
};
|
|
1016
|
+
const latestVersion = await getLatestVersion();
|
|
1017
|
+
await writeCache({
|
|
1018
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1019
|
+
installedVersion,
|
|
1020
|
+
latestVersion
|
|
1021
|
+
});
|
|
1022
|
+
if (latestVersion === null) return {
|
|
1023
|
+
installed: true,
|
|
1024
|
+
installedVersion,
|
|
1025
|
+
latestVersion: null,
|
|
1026
|
+
needsUpdate: false,
|
|
1027
|
+
skipped: true,
|
|
1028
|
+
skipReason: "no-npm"
|
|
1029
|
+
};
|
|
1030
|
+
return {
|
|
1031
|
+
installed: true,
|
|
1032
|
+
installedVersion,
|
|
1033
|
+
latestVersion,
|
|
1034
|
+
needsUpdate: isNewer(installedVersion, latestVersion),
|
|
1035
|
+
skipped: false
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Run `npm install -g @anthropic-ai/claude-code@latest` synchronously.
|
|
1040
|
+
* Throws on failure — the caller decides whether to abort the launch
|
|
1041
|
+
* or continue with the older version.
|
|
1042
|
+
*/
|
|
1043
|
+
async function autoUpdateClaude(latestVersion) {
|
|
1044
|
+
consola.info(`Updating ${NPM_PACKAGE} to ${latestVersion} (this may take ~30s)...`);
|
|
1045
|
+
try {
|
|
1046
|
+
await execFileAsync("npm", [
|
|
1047
|
+
"install",
|
|
1048
|
+
"-g",
|
|
1049
|
+
`${NPM_PACKAGE}@latest`,
|
|
1050
|
+
"--silent"
|
|
1051
|
+
], { timeout: NPM_INSTALL_TIMEOUT_MS });
|
|
1052
|
+
consola.success(`${NPM_PACKAGE} updated to ${latestVersion}`);
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1055
|
+
throw new Error(`npm install failed: ${msg}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
838
1059
|
//#endregion
|
|
839
1060
|
//#region src/lib/port.ts
|
|
840
1061
|
const DEFAULT_PORT = 8787;
|
|
@@ -919,6 +1140,15 @@ const STRIPPED_PARENT_ENV_KEYS = [
|
|
|
919
1140
|
"CLAUDE_CODE_USE_VERTEX",
|
|
920
1141
|
"CLAUDE_CODE_USE_FOUNDRY",
|
|
921
1142
|
"CLAUDE_CONFIG_DIR",
|
|
1143
|
+
"CLAUDE_BRIDGE_OAUTH_TOKEN",
|
|
1144
|
+
"CLAUDE_BRIDGE_BASE_URL",
|
|
1145
|
+
"CLAUDE_BRIDGE_SESSION_INGRESS_URL",
|
|
1146
|
+
"SESSION_INGRESS_URL",
|
|
1147
|
+
"CLAUDE_CODE_REMOTE",
|
|
1148
|
+
"CLAUDE_CODE_CONTAINER_ID",
|
|
1149
|
+
"CLAUDE_CODE_REMOTE_SESSION_ID",
|
|
1150
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
1151
|
+
"CLAUDE_CODE_ADDITIONAL_PROTECTION",
|
|
922
1152
|
"OPENAI_API_KEY",
|
|
923
1153
|
"OPENAI_BASE_URL",
|
|
924
1154
|
"CODEX_HOME"
|
|
@@ -1783,7 +2013,7 @@ function initProxyFromEnv() {
|
|
|
1783
2013
|
//#endregion
|
|
1784
2014
|
//#region package.json
|
|
1785
2015
|
var name = "github-router";
|
|
1786
|
-
var version = "0.3.
|
|
2016
|
+
var version = "0.3.20";
|
|
1787
2017
|
|
|
1788
2018
|
//#endregion
|
|
1789
2019
|
//#region src/lib/approval.ts
|
|
@@ -1897,7 +2127,7 @@ function detectCapabilityMismatch(info, model) {
|
|
|
1897
2127
|
|
|
1898
2128
|
//#endregion
|
|
1899
2129
|
//#region src/lib/stream-relay.ts
|
|
1900
|
-
const ENCODER$
|
|
2130
|
+
const ENCODER$3 = new TextEncoder();
|
|
1901
2131
|
/**
|
|
1902
2132
|
* Detect the family of "controller has already closed" errors that Bun and
|
|
1903
2133
|
* the WHATWG streams runtime throw when an enqueue/close call races with
|
|
@@ -1987,7 +2217,7 @@ function relayAnthropicStream(body, opts) {
|
|
|
1987
2217
|
consola.error(`Upstream stream interrupted at ${opts.routePath}: bytes=${bytesRelayed} errType=${errName} message=${JSON.stringify(errMessage)}`);
|
|
1988
2218
|
const event = buildAnthropicErrorEvent(errName, errMessage);
|
|
1989
2219
|
try {
|
|
1990
|
-
controller.enqueue(ENCODER$
|
|
2220
|
+
controller.enqueue(ENCODER$3.encode(event));
|
|
1991
2221
|
} catch (enqueueError) {
|
|
1992
2222
|
if (!isControllerClosedError(enqueueError)) consola.warn(`Could not deliver error event to consumer at ${opts.routePath}: ${enqueueError instanceof Error ? enqueueError.message : String(enqueueError)}`);
|
|
1993
2223
|
}
|
|
@@ -2471,7 +2701,7 @@ async function searchWeb(query) {
|
|
|
2471
2701
|
|
|
2472
2702
|
//#endregion
|
|
2473
2703
|
//#region src/routes/chat-completions/handler.ts
|
|
2474
|
-
const ENCODER$
|
|
2704
|
+
const ENCODER$2 = new TextEncoder();
|
|
2475
2705
|
function formatSSE$1(chunk) {
|
|
2476
2706
|
const parts = [];
|
|
2477
2707
|
if (chunk.event) parts.push(`event: ${chunk.event}`);
|
|
@@ -2570,7 +2800,7 @@ async function handleCompletion$1(c) {
|
|
|
2570
2800
|
const chunk = pendingFirstChunk;
|
|
2571
2801
|
pendingFirstChunk = void 0;
|
|
2572
2802
|
if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(chunk));
|
|
2573
|
-
safeEnqueue(controller, ENCODER$
|
|
2803
|
+
safeEnqueue(controller, ENCODER$2.encode(formatSSE$1(chunk)));
|
|
2574
2804
|
return;
|
|
2575
2805
|
}
|
|
2576
2806
|
try {
|
|
@@ -2586,7 +2816,7 @@ async function handleCompletion$1(c) {
|
|
|
2586
2816
|
}
|
|
2587
2817
|
if (result.value === void 0 || result.value === null) return;
|
|
2588
2818
|
if (debugEnabled) consola.debug("Streaming chunk:", JSON.stringify(result.value));
|
|
2589
|
-
safeEnqueue(controller, ENCODER$
|
|
2819
|
+
safeEnqueue(controller, ENCODER$2.encode(formatSSE$1(result.value)));
|
|
2590
2820
|
} catch (error) {
|
|
2591
2821
|
upstreamFinished = true;
|
|
2592
2822
|
if (consumerCancelled) {
|
|
@@ -2595,7 +2825,7 @@ async function handleCompletion$1(c) {
|
|
|
2595
2825
|
return;
|
|
2596
2826
|
}
|
|
2597
2827
|
const { errName, errMessage } = logStreamError(c.req.path, error);
|
|
2598
|
-
safeEnqueue(controller, ENCODER$
|
|
2828
|
+
safeEnqueue(controller, ENCODER$2.encode(buildOpenAIErrorEvent(errName, errMessage)));
|
|
2599
2829
|
releaseUpstream(error);
|
|
2600
2830
|
safeClose(controller);
|
|
2601
2831
|
}
|
|
@@ -2720,7 +2950,13 @@ const createResponses = async (payload, modelHeaders, callerSignal) => {
|
|
|
2720
2950
|
};
|
|
2721
2951
|
const response = await tryRefreshAndRetry(doFetch, "/responses");
|
|
2722
2952
|
if (!response.ok) {
|
|
2723
|
-
|
|
2953
|
+
let bodyText;
|
|
2954
|
+
try {
|
|
2955
|
+
bodyText = await response.clone().text();
|
|
2956
|
+
} catch {
|
|
2957
|
+
bodyText = "(failed to read body)";
|
|
2958
|
+
}
|
|
2959
|
+
consola.error(`Failed to create responses: HTTP ${response.status} ${response.statusText} from ${url} — body: ${bodyText.slice(0, 2e3)}`);
|
|
2724
2960
|
throw new HTTPError("Failed to create responses", response);
|
|
2725
2961
|
}
|
|
2726
2962
|
if (payload.stream) return events(response);
|
|
@@ -2784,6 +3020,23 @@ function isEffort(v) {
|
|
|
2784
3020
|
* § "Concurrency cap investigation" for the full justification. */
|
|
2785
3021
|
const MAX_INFLIGHT_TOOLS_CALL = 8;
|
|
2786
3022
|
let inFlightToolsCall = 0;
|
|
3023
|
+
/**
|
|
3024
|
+
* Per-request AbortController registry for `notifications/cancelled`
|
|
3025
|
+
* (Phase D P1.5). When a client times out a tools/call before the
|
|
3026
|
+
* upstream Copilot fetch completes, the JSON-RPC notification:
|
|
3027
|
+
* { jsonrpc:"2.0", method:"notifications/cancelled",
|
|
3028
|
+
* params:{ requestId: "<id>", reason?: "..." } }
|
|
3029
|
+
* arrives. Without handling, the upstream fetch keeps running until
|
|
3030
|
+
* natural completion, leaking the inFlightToolsCall slot for tens of
|
|
3031
|
+
* minutes. Tracking the AbortController lets us abort the fetch and
|
|
3032
|
+
* free the slot immediately.
|
|
3033
|
+
*
|
|
3034
|
+
* Important: per CLAUDE.md "Bun request-signal quirk", we use OUR own
|
|
3035
|
+
* AbortController (NOT c.req.raw.signal which fires after request body
|
|
3036
|
+
* is consumed). The signal is threaded into createResponses /
|
|
3037
|
+
* createChatCompletions's `callerSignal` parameter.
|
|
3038
|
+
*/
|
|
3039
|
+
const inflightAborts = /* @__PURE__ */ new Map();
|
|
2787
3040
|
const RPC_PARSE_ERROR = -32700;
|
|
2788
3041
|
const RPC_INVALID_REQUEST = -32600;
|
|
2789
3042
|
const RPC_METHOD_NOT_FOUND = -32601;
|
|
@@ -2920,7 +3173,7 @@ function toolError(message) {
|
|
|
2920
3173
|
isError: true
|
|
2921
3174
|
};
|
|
2922
3175
|
}
|
|
2923
|
-
async function callPersona(persona, prompt, context, effort) {
|
|
3176
|
+
async function callPersona(persona, prompt, context, effort, signal) {
|
|
2924
3177
|
const resolvedModel = resolveModel(persona.model);
|
|
2925
3178
|
const userText = buildUserText(prompt, context);
|
|
2926
3179
|
if (persona.endpoint === "/v1/responses") {
|
|
@@ -2936,7 +3189,7 @@ async function callPersona(persona, prompt, context, effort) {
|
|
|
2936
3189
|
}],
|
|
2937
3190
|
stream: false,
|
|
2938
3191
|
reasoning: { effort }
|
|
2939
|
-
}));
|
|
3192
|
+
}, void 0, signal));
|
|
2940
3193
|
if (!text$1) return toolError(`persona ${persona.agentName}: empty assistant output`);
|
|
2941
3194
|
return { content: [{
|
|
2942
3195
|
type: "text",
|
|
@@ -2954,7 +3207,7 @@ async function callPersona(persona, prompt, context, effort) {
|
|
|
2954
3207
|
}],
|
|
2955
3208
|
stream: false,
|
|
2956
3209
|
reasoning_effort: effort
|
|
2957
|
-
}));
|
|
3210
|
+
}, void 0, signal));
|
|
2958
3211
|
if (!text) return toolError(`persona ${persona.agentName}: empty assistant output`);
|
|
2959
3212
|
return { content: [{
|
|
2960
3213
|
type: "text",
|
|
@@ -2996,8 +3249,14 @@ async function handleToolsCall(body) {
|
|
|
2996
3249
|
});
|
|
2997
3250
|
inFlightToolsCall++;
|
|
2998
3251
|
const startedAt = Date.now();
|
|
3252
|
+
const abortKey = body.id !== void 0 && body.id !== null ? body.id : void 0;
|
|
3253
|
+
let aborter;
|
|
3254
|
+
if (abortKey !== void 0) {
|
|
3255
|
+
aborter = new AbortController();
|
|
3256
|
+
inflightAborts.set(abortKey, aborter);
|
|
3257
|
+
}
|
|
2999
3258
|
try {
|
|
3000
|
-
const result = await callPersona(persona, prompt, context, effort);
|
|
3259
|
+
const result = await callPersona(persona, prompt, context, effort, aborter?.signal);
|
|
3001
3260
|
logTelemetry({
|
|
3002
3261
|
name: persona.agentName,
|
|
3003
3262
|
model: persona.model,
|
|
@@ -3023,7 +3282,24 @@ async function handleToolsCall(body) {
|
|
|
3023
3282
|
});
|
|
3024
3283
|
} finally {
|
|
3025
3284
|
inFlightToolsCall--;
|
|
3285
|
+
if (abortKey !== void 0) inflightAborts.delete(abortKey);
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* Handle `notifications/cancelled` per JSON-RPC 2.0 + MCP spec.
|
|
3290
|
+
* params.requestId is the id of an in-flight tools/call to abort.
|
|
3291
|
+
* Notifications return no body (handled by isNotification path in
|
|
3292
|
+
* handleRpc); this side-effect frees the in-flight slot.
|
|
3293
|
+
*/
|
|
3294
|
+
function handleCancelledNotification(body) {
|
|
3295
|
+
const requestId = (body.params ?? {}).requestId;
|
|
3296
|
+
if (requestId === void 0 || typeof requestId !== "string" && typeof requestId !== "number") {
|
|
3297
|
+
consola.debug(`[mcp] notifications/cancelled missing or invalid requestId: ${JSON.stringify(requestId)}`);
|
|
3298
|
+
return;
|
|
3026
3299
|
}
|
|
3300
|
+
const aborter = inflightAborts.get(requestId);
|
|
3301
|
+
if (!aborter) return;
|
|
3302
|
+
aborter.abort(/* @__PURE__ */ new Error("client requested cancellation"));
|
|
3027
3303
|
}
|
|
3028
3304
|
async function handleRpc(_c, body) {
|
|
3029
3305
|
if (body === null || typeof body !== "object" || Array.isArray(body)) return {
|
|
@@ -3045,7 +3321,11 @@ async function handleRpc(_c, body) {
|
|
|
3045
3321
|
status: 200,
|
|
3046
3322
|
body: rpcResult(body.id, {
|
|
3047
3323
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
3048
|
-
capabilities: {
|
|
3324
|
+
capabilities: {
|
|
3325
|
+
tools: { listChanged: false },
|
|
3326
|
+
resources: {},
|
|
3327
|
+
prompts: {}
|
|
3328
|
+
},
|
|
3049
3329
|
serverInfo: {
|
|
3050
3330
|
name: SERVER_NAME,
|
|
3051
3331
|
version: SERVER_VERSION
|
|
@@ -3074,6 +3354,61 @@ async function handleRpc(_c, body) {
|
|
|
3074
3354
|
status: 200,
|
|
3075
3355
|
body: await handleToolsCall(body)
|
|
3076
3356
|
};
|
|
3357
|
+
case "resources/list":
|
|
3358
|
+
if (isNotification) return {
|
|
3359
|
+
status: 202,
|
|
3360
|
+
body: null
|
|
3361
|
+
};
|
|
3362
|
+
return {
|
|
3363
|
+
status: 200,
|
|
3364
|
+
body: rpcResult(body.id, { resources: [] })
|
|
3365
|
+
};
|
|
3366
|
+
case "resources/templates/list":
|
|
3367
|
+
if (isNotification) return {
|
|
3368
|
+
status: 202,
|
|
3369
|
+
body: null
|
|
3370
|
+
};
|
|
3371
|
+
return {
|
|
3372
|
+
status: 200,
|
|
3373
|
+
body: rpcResult(body.id, { resourceTemplates: [] })
|
|
3374
|
+
};
|
|
3375
|
+
case "resources/read": {
|
|
3376
|
+
if (isNotification) return {
|
|
3377
|
+
status: 202,
|
|
3378
|
+
body: null
|
|
3379
|
+
};
|
|
3380
|
+
const uri = body.params?.uri;
|
|
3381
|
+
return {
|
|
3382
|
+
status: 200,
|
|
3383
|
+
body: rpcError(body.id, RPC_INVALID_PARAMS, `resources/read: resource URI not found: ${typeof uri === "string" ? uri : "(missing/invalid uri)"}`)
|
|
3384
|
+
};
|
|
3385
|
+
}
|
|
3386
|
+
case "prompts/list":
|
|
3387
|
+
if (isNotification) return {
|
|
3388
|
+
status: 202,
|
|
3389
|
+
body: null
|
|
3390
|
+
};
|
|
3391
|
+
return {
|
|
3392
|
+
status: 200,
|
|
3393
|
+
body: rpcResult(body.id, { prompts: [] })
|
|
3394
|
+
};
|
|
3395
|
+
case "prompts/get": {
|
|
3396
|
+
if (isNotification) return {
|
|
3397
|
+
status: 202,
|
|
3398
|
+
body: null
|
|
3399
|
+
};
|
|
3400
|
+
const name$1 = body.params?.name;
|
|
3401
|
+
return {
|
|
3402
|
+
status: 200,
|
|
3403
|
+
body: rpcError(body.id, RPC_INVALID_PARAMS, `prompts/get: prompt name not found: ${typeof name$1 === "string" ? name$1 : "(missing/invalid name)"}`)
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
case "notifications/cancelled":
|
|
3407
|
+
handleCancelledNotification(body);
|
|
3408
|
+
return {
|
|
3409
|
+
status: 202,
|
|
3410
|
+
body: null
|
|
3411
|
+
};
|
|
3077
3412
|
case "ping":
|
|
3078
3413
|
if (isNotification) return {
|
|
3079
3414
|
status: 202,
|
|
@@ -3240,6 +3575,742 @@ async function countTokens(body, extraHeaders) {
|
|
|
3240
3575
|
return response;
|
|
3241
3576
|
}
|
|
3242
3577
|
|
|
3578
|
+
//#endregion
|
|
3579
|
+
//#region src/services/advisor/advisor.ts
|
|
3580
|
+
const ENCODER$1 = new TextEncoder();
|
|
3581
|
+
/** The tool name we inject for Copilot. Double-underscore prefix
|
|
3582
|
+
* avoids collision with any user MCP server's `advisor` tool. */
|
|
3583
|
+
const ADVISOR_INTERNAL_TOOL_NAME = "__anthropic_advisor";
|
|
3584
|
+
/** The Anthropic-spec name used in the translated server_tool_use
|
|
3585
|
+
* block sent to the client. cc-backup AdvisorMessage.tsx requires
|
|
3586
|
+
* this exact name to render the advisor spinner. */
|
|
3587
|
+
const ADVISOR_CLIENT_TOOL_NAME = "advisor";
|
|
3588
|
+
/** Hard cap on advisor calls per request to bound runaway behavior.
|
|
3589
|
+
* Matches Phase G's loop bound; ADVISOR is typically called 1-3
|
|
3590
|
+
* times per session per cc-backup ADVISOR_TOOL_INSTRUCTIONS. */
|
|
3591
|
+
const ADVISOR_MAX_TURNS = 16;
|
|
3592
|
+
/** Default advisor model + reasoning effort. Per gemini-critic + user
|
|
3593
|
+
* direction: hardcode to a cross-lab model (gpt-5.5 — Copilot's
|
|
3594
|
+
* /responses-only flagship) at xhigh effort. The cross-lab choice
|
|
3595
|
+
* gives a true "second set of eyes" instead of the main model
|
|
3596
|
+
* reviewing itself; xhigh effort buys the deep-dive reasoning that
|
|
3597
|
+
* matches Anthropic's own ADVISOR (which uses a stronger reviewer
|
|
3598
|
+
* model — Opus 4.6/Sonnet 4.6 typically). */
|
|
3599
|
+
const ADVISOR_DEFAULT_MODEL = "gpt-5.5";
|
|
3600
|
+
const ADVISOR_DEFAULT_EFFORT = "xhigh";
|
|
3601
|
+
/** ADVISOR_TOOL_INSTRUCTIONS verbatim from cc-backup
|
|
3602
|
+
* src/utils/advisor.ts — describes when the model should invoke
|
|
3603
|
+
* the advisor. Long-form prose; see source for justification. */
|
|
3604
|
+
const ADVISOR_TOOL_INSTRUCTIONS = `# Advisor Tool
|
|
3605
|
+
|
|
3606
|
+
You have access to an \`advisor\` tool backed by a stronger reviewer model. It takes NO parameters -- when you call it, your entire conversation history is automatically forwarded. The advisor sees the task, every tool call you've made, every result you've seen.
|
|
3607
|
+
|
|
3608
|
+
Call advisor BEFORE substantive work -- before writing code, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, reading code, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are.
|
|
3609
|
+
|
|
3610
|
+
Also call advisor:
|
|
3611
|
+
- When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, stage the change, save the result. The advisor call takes time; if the session ends during it, a durable result persists and an unwritten one doesn't.
|
|
3612
|
+
- When stuck -- errors recurring, approach not converging, results that don't fit.
|
|
3613
|
+
- When considering a change of approach.
|
|
3614
|
+
|
|
3615
|
+
On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling -- the advisor adds most of its value on the first call, before the approach crystallizes.
|
|
3616
|
+
|
|
3617
|
+
Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim (the file says X, the code does Y), adapt. A passing self-test is not evidence the advice is wrong -- it's evidence your test doesn't check what the advice is checking.
|
|
3618
|
+
|
|
3619
|
+
If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call -- "I found X, you suggest Y, which constraint breaks the tie?" The advisor saw your evidence but may have underweighted it; a reconcile call is cheaper than committing to the wrong branch.`;
|
|
3620
|
+
const ADVISOR_OPT_OUT_ENV = "CLAUDE_CODE_DISABLE_ADVISOR_TOOL";
|
|
3621
|
+
/**
|
|
3622
|
+
* Detect whether the request asked for ADVISOR (incoming
|
|
3623
|
+
* `anthropic-beta` header contains an `advisor-tool-` prefix). Also
|
|
3624
|
+
* respects the `CLAUDE_CODE_DISABLE_ADVISOR_TOOL` opt-out env var
|
|
3625
|
+
* (set by the user to globally disable; matches cc-backup advisor.ts
|
|
3626
|
+
* line 61).
|
|
3627
|
+
*/
|
|
3628
|
+
function isAdvisorRequested(rawBetaHeader) {
|
|
3629
|
+
if (!rawBetaHeader) return false;
|
|
3630
|
+
if (process.env[ADVISOR_OPT_OUT_ENV]) return false;
|
|
3631
|
+
return rawBetaHeader.split(",").map((s) => s.trim()).some((v) => v.startsWith("advisor-tool-"));
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* Inject the __anthropic_advisor tool definition into the body's tools
|
|
3635
|
+
* array. Returns a new body string. Idempotent — if the tool is already
|
|
3636
|
+
* present (e.g. the user's MCP shadowed it) we leave the existing one
|
|
3637
|
+
* alone and return the body unchanged.
|
|
3638
|
+
*
|
|
3639
|
+
* Also strips any tool entry with `type: "advisor_*"` (Anthropic API's
|
|
3640
|
+
* native server-side advisor tool — `advisor_20260301` and future
|
|
3641
|
+
* variants). When `CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL=1` is
|
|
3642
|
+
* set, Claude Code injects its own advisor tool with this type into
|
|
3643
|
+
* `tools[]`. Copilot 400s on the unknown tool type ("Input tag
|
|
3644
|
+
* 'advisor_20260301' found using 'type' does not match any of the
|
|
3645
|
+
* expected tags"), so the proxy must strip it before forwarding while
|
|
3646
|
+
* still injecting our custom `__anthropic_advisor` tool that the model
|
|
3647
|
+
* can invoke. The proxy's intercept on the response stream then
|
|
3648
|
+
* translates the model's `tool_use{__anthropic_advisor}` to the
|
|
3649
|
+
* client-shape `server_tool_use{name:"advisor"}` + `advisor_tool_result`
|
|
3650
|
+
* blocks the client expects.
|
|
3651
|
+
*/
|
|
3652
|
+
function injectAdvisorTool(rawBody) {
|
|
3653
|
+
let parsed;
|
|
3654
|
+
try {
|
|
3655
|
+
parsed = JSON.parse(rawBody);
|
|
3656
|
+
} catch {
|
|
3657
|
+
return rawBody;
|
|
3658
|
+
}
|
|
3659
|
+
const rawTools = Array.isArray(parsed.tools) ? parsed.tools : [];
|
|
3660
|
+
const tools = rawTools.filter((t) => {
|
|
3661
|
+
if (typeof t !== "object" || t === null) return true;
|
|
3662
|
+
const type = t.type;
|
|
3663
|
+
return typeof type !== "string" || !type.startsWith("advisor_");
|
|
3664
|
+
});
|
|
3665
|
+
const stripped = tools.length !== rawTools.length;
|
|
3666
|
+
const alreadyInjected = tools.some((t) => t?.name === ADVISOR_INTERNAL_TOOL_NAME);
|
|
3667
|
+
if (alreadyInjected && !stripped) return rawBody;
|
|
3668
|
+
parsed.tools = alreadyInjected ? tools : [...tools, {
|
|
3669
|
+
name: ADVISOR_INTERNAL_TOOL_NAME,
|
|
3670
|
+
description: ADVISOR_TOOL_INSTRUCTIONS,
|
|
3671
|
+
input_schema: {
|
|
3672
|
+
type: "object",
|
|
3673
|
+
properties: {},
|
|
3674
|
+
required: []
|
|
3675
|
+
}
|
|
3676
|
+
}];
|
|
3677
|
+
return JSON.stringify(parsed);
|
|
3678
|
+
}
|
|
3679
|
+
/** Character budget for rendered conversation text passed to the
|
|
3680
|
+
* advisor model. gpt-5.5 (default advisor) caps prompt input at
|
|
3681
|
+
* 272,000 tokens. At a conservative ~3 chars/token (mixed prose +
|
|
3682
|
+
* code + JSON), 720,000 chars renders to ≈240,000 tokens, leaving
|
|
3683
|
+
* ~32,000 tokens of headroom for the system prompt and per-turn
|
|
3684
|
+
* framing overhead. Without this cap, long Claude Code sessions
|
|
3685
|
+
* produce 400 `model_max_prompt_tokens_exceeded` from /v1/responses
|
|
3686
|
+
* and the advisor falls back silently. */
|
|
3687
|
+
const ADVISOR_MAX_CONVERSATION_CHARS = 72e4;
|
|
3688
|
+
/**
|
|
3689
|
+
* Render an Anthropic-shape conversation (messages array with
|
|
3690
|
+
* role/content blocks) as a single human-readable text blob. Used
|
|
3691
|
+
* as the input to the advisor model (gpt-5.5 via /v1/responses
|
|
3692
|
+
* doesn't have a 1:1 mapping for Anthropic's tool_use/tool_result
|
|
3693
|
+
* blocks; serializing to text preserves the semantics — the advisor
|
|
3694
|
+
* just needs to READ the conversation, not produce more of it).
|
|
3695
|
+
*
|
|
3696
|
+
* Front-truncates oldest turns when the rendered output would exceed
|
|
3697
|
+
* `maxChars`. The advisor cares more about current state (latest
|
|
3698
|
+
* tool calls, errors, in-flight task) than the original prompt —
|
|
3699
|
+
* mirrors Claude Code's own context-truncation strategy. When any
|
|
3700
|
+
* turns are dropped, prepends a `[TRUNCATED: N earlier turn(s)
|
|
3701
|
+
* omitted ...]` notice so the advisor knows the transcript is
|
|
3702
|
+
* partial and can flag if it needs the missing context.
|
|
3703
|
+
*/
|
|
3704
|
+
function renderConversationAsText(conversation, maxChars = ADVISOR_MAX_CONVERSATION_CHARS) {
|
|
3705
|
+
const turnBlocks = [];
|
|
3706
|
+
for (let i = 0; i < conversation.length; i++) {
|
|
3707
|
+
const msg = conversation[i];
|
|
3708
|
+
const role = msg.role ?? "unknown";
|
|
3709
|
+
const block = [`### Turn ${i + 1} — ${role}`];
|
|
3710
|
+
const content = msg.content;
|
|
3711
|
+
if (typeof content === "string") block.push(content);
|
|
3712
|
+
else if (Array.isArray(content)) for (const part of content) {
|
|
3713
|
+
if (typeof part !== "object" || part === null) continue;
|
|
3714
|
+
const b = part;
|
|
3715
|
+
if (b.type === "text" && typeof b.text === "string") block.push(b.text);
|
|
3716
|
+
else if (b.type === "tool_use") block.push(`[tool_use ${b.name ?? "?"}(${b.id ?? "?"}): ${JSON.stringify(b.input ?? {})}]`);
|
|
3717
|
+
else if (b.type === "tool_result") {
|
|
3718
|
+
const c = typeof b.content === "string" ? b.content : JSON.stringify(b.content);
|
|
3719
|
+
block.push(`[tool_result ${b.tool_use_id ?? "?"}]:\n${c}`);
|
|
3720
|
+
} else block.push(`[${b.type}: ${JSON.stringify(b).slice(0, 500)}]`);
|
|
3721
|
+
}
|
|
3722
|
+
block.push("");
|
|
3723
|
+
turnBlocks.push(block.join("\n"));
|
|
3724
|
+
}
|
|
3725
|
+
let totalChars = 0;
|
|
3726
|
+
let firstKeptIdx = turnBlocks.length;
|
|
3727
|
+
for (let i = turnBlocks.length - 1; i >= 0; i--) {
|
|
3728
|
+
const len = turnBlocks[i].length + 1;
|
|
3729
|
+
if (totalChars + len > maxChars) break;
|
|
3730
|
+
totalChars += len;
|
|
3731
|
+
firstKeptIdx = i;
|
|
3732
|
+
}
|
|
3733
|
+
if (firstKeptIdx === turnBlocks.length && turnBlocks.length > 0) {
|
|
3734
|
+
const tail = turnBlocks[turnBlocks.length - 1].slice(-(maxChars - 200));
|
|
3735
|
+
return `[TRUNCATED: conversation too long for advisor model context; only the tail of the latest (turn ${turnBlocks.length}) is shown]\n\n` + tail;
|
|
3736
|
+
}
|
|
3737
|
+
const kept = turnBlocks.slice(firstKeptIdx);
|
|
3738
|
+
if (firstKeptIdx > 0) kept.unshift(`[TRUNCATED: ${firstKeptIdx} earlier turn(s) omitted to fit advisor model context budget; ${turnBlocks.length - firstKeptIdx} most-recent turn(s) shown below]\n`);
|
|
3739
|
+
return kept.join("\n");
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* Run the advisor model with the full conversation context. Returns
|
|
3743
|
+
* the advisor's text response.
|
|
3744
|
+
*
|
|
3745
|
+
* Routes by model family:
|
|
3746
|
+
* - gpt-5.x / codex / o-series (have `/responses` in supported_endpoints):
|
|
3747
|
+
* use createResponses with `reasoning.effort` set. This is the
|
|
3748
|
+
* default path — gpt-5.5 at xhigh effort.
|
|
3749
|
+
* - claude-* (no `/responses`): fall back to createMessages.
|
|
3750
|
+
*
|
|
3751
|
+
* The conversation is serialized to text via renderConversationAsText
|
|
3752
|
+
* so the advisor model (which may not natively understand Anthropic's
|
|
3753
|
+
* tool_use/tool_result block shapes) sees a flat readable transcript.
|
|
3754
|
+
* This loses some structural fidelity but matches the spirit of
|
|
3755
|
+
* Anthropic's own ADVISOR ("see the whole task + every tool call +
|
|
3756
|
+
* every result").
|
|
3757
|
+
*/
|
|
3758
|
+
async function runAdvisor(conversation, advisorModel, advisorEffort) {
|
|
3759
|
+
const advisorSystem = "You are an expert advisor reviewing an in-progress Claude Code session. The transcript below is the work-in-progress (turns numbered, with tool calls and results inlined). Read carefully and provide concrete, actionable advice on the next step or course-correction. Be specific — cite the parts of the transcript you're responding to. If the assistant is on the right track, say so explicitly. If they're stuck or off-track, name the specific assumption or step to revisit. Aim for 2-5 paragraphs of substantive guidance.";
|
|
3760
|
+
const conversationText = renderConversationAsText(conversation);
|
|
3761
|
+
const resolvedAdvisorModel = resolveModel(advisorModel);
|
|
3762
|
+
if (/^(gpt-|o\d|.*codex)/i.test(resolvedAdvisorModel)) {
|
|
3763
|
+
const response = await createResponses({
|
|
3764
|
+
model: resolvedAdvisorModel,
|
|
3765
|
+
instructions: advisorSystem,
|
|
3766
|
+
input: [{
|
|
3767
|
+
role: "user",
|
|
3768
|
+
content: [{
|
|
3769
|
+
type: "input_text",
|
|
3770
|
+
text: conversationText
|
|
3771
|
+
}]
|
|
3772
|
+
}],
|
|
3773
|
+
stream: false,
|
|
3774
|
+
reasoning: { effort: advisorEffort }
|
|
3775
|
+
});
|
|
3776
|
+
const out = [];
|
|
3777
|
+
for (const item of response.output) {
|
|
3778
|
+
if (typeof item !== "object" || item === null) continue;
|
|
3779
|
+
const obj = item;
|
|
3780
|
+
if (obj.type !== "message" || obj.role !== "assistant") continue;
|
|
3781
|
+
const content = obj.content;
|
|
3782
|
+
if (!Array.isArray(content)) continue;
|
|
3783
|
+
for (const part of content) {
|
|
3784
|
+
if (typeof part !== "object" || part === null) continue;
|
|
3785
|
+
const p = part;
|
|
3786
|
+
if ((p.type === "output_text" || p.type === "text") && typeof p.text === "string") out.push(p.text);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
const text$1 = out.join("");
|
|
3790
|
+
if (!text$1) throw new Error(`Advisor model ${resolvedAdvisorModel} returned empty assistant output`);
|
|
3791
|
+
return text$1;
|
|
3792
|
+
}
|
|
3793
|
+
const json = await (await createMessages(JSON.stringify({
|
|
3794
|
+
model: resolvedAdvisorModel,
|
|
3795
|
+
max_tokens: 4096,
|
|
3796
|
+
system: advisorSystem,
|
|
3797
|
+
messages: [{
|
|
3798
|
+
role: "user",
|
|
3799
|
+
content: conversationText
|
|
3800
|
+
}],
|
|
3801
|
+
stream: false
|
|
3802
|
+
}), {})).json();
|
|
3803
|
+
const text = (Array.isArray(json.content) ? json.content : []).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n\n");
|
|
3804
|
+
if (!text) throw new Error(`Advisor model ${resolvedAdvisorModel} returned empty response`);
|
|
3805
|
+
return text;
|
|
3806
|
+
}
|
|
3807
|
+
/**
|
|
3808
|
+
* Derive a spec-compliant `srvtoolu_*` id for a client-facing
|
|
3809
|
+
* `server_tool_use` (and matching `advisor_tool_result.tool_use_id`)
|
|
3810
|
+
* from the upstream model's `toolu_*` id.
|
|
3811
|
+
*
|
|
3812
|
+
* Anthropic spec: `^srvtoolu_[a-zA-Z0-9_]+$`. If the upstream id
|
|
3813
|
+
* suffix contains chars outside that charset (e.g., a hyphenated id
|
|
3814
|
+
* from a non-Anthropic provider, or a corrupt id), fall back to a
|
|
3815
|
+
* synthesized stable id keyed by the SSE block index. Defensive
|
|
3816
|
+
* against edge cases that would otherwise emit a malformed block —
|
|
3817
|
+
* spec violation in either direction is a 400.
|
|
3818
|
+
*/
|
|
3819
|
+
function toClientServerToolUseId(id, fallbackIndex) {
|
|
3820
|
+
const suffix = id.startsWith("toolu_") ? id.slice(6) : id;
|
|
3821
|
+
if (/^[a-zA-Z0-9_]+$/.test(suffix)) return `srvtoolu_${suffix}`;
|
|
3822
|
+
return `srvtoolu_advisor_${fallbackIndex}`;
|
|
3823
|
+
}
|
|
3824
|
+
/**
|
|
3825
|
+
* Build an SSE event line in the canonical Anthropic shape:
|
|
3826
|
+
* event: <type>
|
|
3827
|
+
* data: <json>
|
|
3828
|
+
* <blank>
|
|
3829
|
+
*/
|
|
3830
|
+
function sseEvent(type, data) {
|
|
3831
|
+
return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
3832
|
+
}
|
|
3833
|
+
/**
|
|
3834
|
+
* The streaming translate-loop. Returns a ReadableStream<Uint8Array>
|
|
3835
|
+
* suitable to wrap with Hono's c.body() / new Response().
|
|
3836
|
+
*
|
|
3837
|
+
* @param firstResponse The first Copilot streaming response
|
|
3838
|
+
* @param initialConversation The conversation messages from the
|
|
3839
|
+
* incoming request (used as the starting context for advisor calls
|
|
3840
|
+
* and continuation Copilot calls).
|
|
3841
|
+
* @param baseBody Parsed initial request body (model, max_tokens,
|
|
3842
|
+
* system, etc.) — used as the template for continuation Copilot calls.
|
|
3843
|
+
* @param requestHeaders Extra headers (model-specific + filtered
|
|
3844
|
+
* anthropic-beta) for downstream Copilot calls.
|
|
3845
|
+
* @param advisorModel Which model to route advisor calls to. Defaults
|
|
3846
|
+
* to ADVISOR_DEFAULT_MODEL (cross-lab).
|
|
3847
|
+
*/
|
|
3848
|
+
function buildAdvisorStream(opts) {
|
|
3849
|
+
const advisorModel = opts.advisorModel ?? ADVISOR_DEFAULT_MODEL;
|
|
3850
|
+
const advisorEffort = opts.advisorEffort ?? ADVISOR_DEFAULT_EFFORT;
|
|
3851
|
+
return new ReadableStream({ async start(controller) {
|
|
3852
|
+
const conversation = [...opts.initialConversation];
|
|
3853
|
+
let messageStartForwarded = false;
|
|
3854
|
+
let nextSyntheticIndex = 0;
|
|
3855
|
+
let turnsRun = 0;
|
|
3856
|
+
const safeEnqueue = (bytes) => {
|
|
3857
|
+
try {
|
|
3858
|
+
controller.enqueue(bytes);
|
|
3859
|
+
return true;
|
|
3860
|
+
} catch (err) {
|
|
3861
|
+
if (isControllerClosedError(err)) return false;
|
|
3862
|
+
throw err;
|
|
3863
|
+
}
|
|
3864
|
+
};
|
|
3865
|
+
const safeEnqueueEvent = (type, data) => safeEnqueue(ENCODER$1.encode(sseEvent(type, data)));
|
|
3866
|
+
async function processOneTurn(response) {
|
|
3867
|
+
const capturedBlocks = [];
|
|
3868
|
+
let advisorToolUse = null;
|
|
3869
|
+
const indexToBlock = /* @__PURE__ */ new Map();
|
|
3870
|
+
for await (const ev of events(response)) {
|
|
3871
|
+
if (!ev.event || !ev.data) continue;
|
|
3872
|
+
let payload;
|
|
3873
|
+
try {
|
|
3874
|
+
payload = JSON.parse(ev.data);
|
|
3875
|
+
} catch {
|
|
3876
|
+
if (!safeEnqueue(ENCODER$1.encode(`event: ${ev.event}\ndata: ${ev.data}\n\n`))) return {
|
|
3877
|
+
capturedBlocks,
|
|
3878
|
+
advisorToolUse
|
|
3879
|
+
};
|
|
3880
|
+
continue;
|
|
3881
|
+
}
|
|
3882
|
+
switch (ev.event) {
|
|
3883
|
+
case "message_start":
|
|
3884
|
+
if (!messageStartForwarded) {
|
|
3885
|
+
if (!safeEnqueueEvent(ev.event, payload)) return {
|
|
3886
|
+
capturedBlocks,
|
|
3887
|
+
advisorToolUse
|
|
3888
|
+
};
|
|
3889
|
+
messageStartForwarded = true;
|
|
3890
|
+
}
|
|
3891
|
+
continue;
|
|
3892
|
+
case "content_block_start": {
|
|
3893
|
+
const block = payload.content_block;
|
|
3894
|
+
const upstreamIndex = payload.index;
|
|
3895
|
+
if (block && upstreamIndex !== void 0) {
|
|
3896
|
+
const myIndex = nextSyntheticIndex++;
|
|
3897
|
+
if (block.type === "tool_use" && block.name === ADVISOR_INTERNAL_TOOL_NAME) {
|
|
3898
|
+
const id = typeof block.id === "string" ? block.id : `toolu_advisor_${myIndex}`;
|
|
3899
|
+
advisorToolUse = {
|
|
3900
|
+
index: myIndex,
|
|
3901
|
+
id,
|
|
3902
|
+
clientId: toClientServerToolUseId(id, myIndex),
|
|
3903
|
+
inputJson: ""
|
|
3904
|
+
};
|
|
3905
|
+
const translated = {
|
|
3906
|
+
...payload,
|
|
3907
|
+
index: myIndex,
|
|
3908
|
+
content_block: {
|
|
3909
|
+
type: "server_tool_use",
|
|
3910
|
+
id: advisorToolUse.clientId,
|
|
3911
|
+
name: ADVISOR_CLIENT_TOOL_NAME,
|
|
3912
|
+
input: {}
|
|
3913
|
+
}
|
|
3914
|
+
};
|
|
3915
|
+
if (!safeEnqueueEvent(ev.event, translated)) return {
|
|
3916
|
+
capturedBlocks,
|
|
3917
|
+
advisorToolUse
|
|
3918
|
+
};
|
|
3919
|
+
const captured = {
|
|
3920
|
+
block: {
|
|
3921
|
+
type: "tool_use",
|
|
3922
|
+
id,
|
|
3923
|
+
name: ADVISOR_INTERNAL_TOOL_NAME,
|
|
3924
|
+
input: {}
|
|
3925
|
+
},
|
|
3926
|
+
partialJson: "",
|
|
3927
|
+
advisorReplay: { id }
|
|
3928
|
+
};
|
|
3929
|
+
capturedBlocks.push(captured);
|
|
3930
|
+
indexToBlock.set(upstreamIndex, captured);
|
|
3931
|
+
} else {
|
|
3932
|
+
const reindexed = {
|
|
3933
|
+
...payload,
|
|
3934
|
+
index: myIndex
|
|
3935
|
+
};
|
|
3936
|
+
if (!safeEnqueueEvent(ev.event, reindexed)) return {
|
|
3937
|
+
capturedBlocks,
|
|
3938
|
+
advisorToolUse
|
|
3939
|
+
};
|
|
3940
|
+
const captured = {
|
|
3941
|
+
block: { ...block },
|
|
3942
|
+
partialJson: ""
|
|
3943
|
+
};
|
|
3944
|
+
capturedBlocks.push(captured);
|
|
3945
|
+
indexToBlock.set(upstreamIndex, captured);
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
continue;
|
|
3949
|
+
}
|
|
3950
|
+
case "content_block_delta": {
|
|
3951
|
+
const upstreamIndex = payload.index;
|
|
3952
|
+
const delta = payload.delta;
|
|
3953
|
+
if (upstreamIndex !== void 0) {
|
|
3954
|
+
const captured = upstreamIndex !== void 0 ? indexToBlock.get(upstreamIndex) : void 0;
|
|
3955
|
+
const reindexed = {
|
|
3956
|
+
...payload,
|
|
3957
|
+
index: captured ? capturedBlocks.indexOf(captured) >= 0 ? nextSyntheticIndex - capturedBlocks.length + capturedBlocks.indexOf(captured) : upstreamIndex : upstreamIndex
|
|
3958
|
+
};
|
|
3959
|
+
if (!safeEnqueueEvent(ev.event, reindexed)) return {
|
|
3960
|
+
capturedBlocks,
|
|
3961
|
+
advisorToolUse
|
|
3962
|
+
};
|
|
3963
|
+
if (captured && delta) {
|
|
3964
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") captured.block.text = (captured.block.text ?? "") + delta.text;
|
|
3965
|
+
else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") captured.block.thinking = (captured.block.thinking ?? "") + delta.thinking;
|
|
3966
|
+
else if (delta.type === "signature_delta" && typeof delta.signature === "string") captured.block.signature = (captured.block.signature ?? "") + delta.signature;
|
|
3967
|
+
else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") captured.partialJson += delta.partial_json;
|
|
3968
|
+
else if (delta.type === "citations_delta" && delta.citation) {
|
|
3969
|
+
if (!Array.isArray(captured.block.citations)) captured.block.citations = [];
|
|
3970
|
+
captured.block.citations.push(delta.citation);
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
} else if (!safeEnqueueEvent(ev.event, payload)) return {
|
|
3974
|
+
capturedBlocks,
|
|
3975
|
+
advisorToolUse
|
|
3976
|
+
};
|
|
3977
|
+
continue;
|
|
3978
|
+
}
|
|
3979
|
+
case "content_block_stop": {
|
|
3980
|
+
const upstreamIndex = payload.index;
|
|
3981
|
+
const captured = upstreamIndex !== void 0 ? indexToBlock.get(upstreamIndex) : void 0;
|
|
3982
|
+
const reindexed = {
|
|
3983
|
+
...payload,
|
|
3984
|
+
index: captured ? nextSyntheticIndex - capturedBlocks.length + capturedBlocks.indexOf(captured) : upstreamIndex ?? 0
|
|
3985
|
+
};
|
|
3986
|
+
if (!safeEnqueueEvent(ev.event, reindexed)) return {
|
|
3987
|
+
capturedBlocks,
|
|
3988
|
+
advisorToolUse
|
|
3989
|
+
};
|
|
3990
|
+
if (captured) {
|
|
3991
|
+
if (captured.block.type === "tool_use" && captured.partialJson.length > 0) try {
|
|
3992
|
+
captured.block.input = JSON.parse(captured.partialJson);
|
|
3993
|
+
} catch (err) {
|
|
3994
|
+
consola.warn(`advisor: malformed input_json_delta for tool_use id=${captured.block.id ?? "?"} name=${captured.block.name ?? "?"} partialJson.length=${captured.partialJson.length} parseError=${err instanceof Error ? err.message : String(err)}`);
|
|
3995
|
+
captured.block.input = {};
|
|
3996
|
+
}
|
|
3997
|
+
if (captured.block.type === "text" && (typeof captured.block.text !== "string" || captured.block.text.length === 0)) captured.dropFromReplay = true;
|
|
3998
|
+
}
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
case "message_delta":
|
|
4002
|
+
if (!safeEnqueueEvent(ev.event, payload)) return {
|
|
4003
|
+
capturedBlocks,
|
|
4004
|
+
advisorToolUse
|
|
4005
|
+
};
|
|
4006
|
+
continue;
|
|
4007
|
+
case "message_stop":
|
|
4008
|
+
if (advisorToolUse) return {
|
|
4009
|
+
capturedBlocks,
|
|
4010
|
+
advisorToolUse
|
|
4011
|
+
};
|
|
4012
|
+
if (!safeEnqueueEvent(ev.event, payload)) return {
|
|
4013
|
+
capturedBlocks,
|
|
4014
|
+
advisorToolUse
|
|
4015
|
+
};
|
|
4016
|
+
return {
|
|
4017
|
+
capturedBlocks,
|
|
4018
|
+
advisorToolUse
|
|
4019
|
+
};
|
|
4020
|
+
default: if (!safeEnqueueEvent(ev.event, payload)) return {
|
|
4021
|
+
capturedBlocks,
|
|
4022
|
+
advisorToolUse
|
|
4023
|
+
};
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
return {
|
|
4027
|
+
capturedBlocks,
|
|
4028
|
+
advisorToolUse
|
|
4029
|
+
};
|
|
4030
|
+
}
|
|
4031
|
+
try {
|
|
4032
|
+
let response = opts.firstResponse;
|
|
4033
|
+
for (turnsRun = 0; turnsRun < ADVISOR_MAX_TURNS; turnsRun++) {
|
|
4034
|
+
const { capturedBlocks, advisorToolUse } = await processOneTurn(response);
|
|
4035
|
+
if (!advisorToolUse) return;
|
|
4036
|
+
const assistantTurn = {
|
|
4037
|
+
role: "assistant",
|
|
4038
|
+
content: capturedBlocks.filter((c) => !c.dropFromReplay).map((c) => {
|
|
4039
|
+
if (c.advisorReplay) {
|
|
4040
|
+
const input = typeof c.block.input === "object" && c.block.input !== null ? c.block.input : {};
|
|
4041
|
+
return {
|
|
4042
|
+
type: "tool_use",
|
|
4043
|
+
id: c.advisorReplay.id,
|
|
4044
|
+
name: ADVISOR_INTERNAL_TOOL_NAME,
|
|
4045
|
+
input
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
return c.block;
|
|
4049
|
+
})
|
|
4050
|
+
};
|
|
4051
|
+
conversation.push(assistantTurn);
|
|
4052
|
+
let advisorText;
|
|
4053
|
+
try {
|
|
4054
|
+
advisorText = await runAdvisor(conversation, advisorModel, advisorEffort);
|
|
4055
|
+
} catch (err) {
|
|
4056
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4057
|
+
consola.warn(`Advisor model call failed: ${msg}`);
|
|
4058
|
+
advisorText = `[Advisor unavailable: ${msg}. Continuing without external review — proceed with caution and consider self-checking against your primary-source evidence.]`;
|
|
4059
|
+
}
|
|
4060
|
+
const resultIndex = nextSyntheticIndex++;
|
|
4061
|
+
if (!safeEnqueueEvent("content_block_start", {
|
|
4062
|
+
type: "content_block_start",
|
|
4063
|
+
index: resultIndex,
|
|
4064
|
+
content_block: {
|
|
4065
|
+
type: "advisor_tool_result",
|
|
4066
|
+
tool_use_id: advisorToolUse.clientId,
|
|
4067
|
+
content: {
|
|
4068
|
+
type: "advisor_result",
|
|
4069
|
+
text: advisorText
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
})) return;
|
|
4073
|
+
if (!safeEnqueueEvent("content_block_stop", {
|
|
4074
|
+
type: "content_block_stop",
|
|
4075
|
+
index: resultIndex
|
|
4076
|
+
})) return;
|
|
4077
|
+
conversation.push({
|
|
4078
|
+
role: "user",
|
|
4079
|
+
content: [{
|
|
4080
|
+
type: "tool_result",
|
|
4081
|
+
tool_use_id: advisorToolUse.id,
|
|
4082
|
+
content: advisorText
|
|
4083
|
+
}]
|
|
4084
|
+
});
|
|
4085
|
+
response = await createMessages(JSON.stringify({
|
|
4086
|
+
...opts.baseBody,
|
|
4087
|
+
messages: conversation,
|
|
4088
|
+
stream: true
|
|
4089
|
+
}), opts.requestHeaders);
|
|
4090
|
+
}
|
|
4091
|
+
const finalIndex = nextSyntheticIndex++;
|
|
4092
|
+
safeEnqueueEvent("content_block_start", {
|
|
4093
|
+
type: "content_block_start",
|
|
4094
|
+
index: finalIndex,
|
|
4095
|
+
content_block: {
|
|
4096
|
+
type: "text",
|
|
4097
|
+
text: ""
|
|
4098
|
+
}
|
|
4099
|
+
});
|
|
4100
|
+
safeEnqueueEvent("content_block_delta", {
|
|
4101
|
+
type: "content_block_delta",
|
|
4102
|
+
index: finalIndex,
|
|
4103
|
+
delta: {
|
|
4104
|
+
type: "text_delta",
|
|
4105
|
+
text: `\n\n[Advisor loop exceeded ${ADVISOR_MAX_TURNS} turns; halting]`
|
|
4106
|
+
}
|
|
4107
|
+
});
|
|
4108
|
+
safeEnqueueEvent("content_block_stop", {
|
|
4109
|
+
type: "content_block_stop",
|
|
4110
|
+
index: finalIndex
|
|
4111
|
+
});
|
|
4112
|
+
safeEnqueueEvent("message_stop", { type: "message_stop" });
|
|
4113
|
+
} catch (err) {
|
|
4114
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4115
|
+
consola.error(`Advisor stream error: ${msg}`);
|
|
4116
|
+
safeEnqueueEvent("error", {
|
|
4117
|
+
type: "error",
|
|
4118
|
+
error: {
|
|
4119
|
+
type: "api_error",
|
|
4120
|
+
message: `advisor loop failed: ${msg}`
|
|
4121
|
+
}
|
|
4122
|
+
});
|
|
4123
|
+
} finally {
|
|
4124
|
+
try {
|
|
4125
|
+
controller.close();
|
|
4126
|
+
} catch {}
|
|
4127
|
+
}
|
|
4128
|
+
} });
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
//#endregion
|
|
4132
|
+
//#region src/lib/sanitize-anthropic-body.ts
|
|
4133
|
+
/**
|
|
4134
|
+
* Convert a `srvtoolu_*` id to the matching `toolu_*` id used in the
|
|
4135
|
+
* Copilot-replay shape (`tool_use.id` must match `^toolu_*$`). For
|
|
4136
|
+
* any other input shape, fall back to a synthesized `toolu_advisor_N`
|
|
4137
|
+
* id.
|
|
4138
|
+
*/
|
|
4139
|
+
function toCopilotToolUseId(srvId, fallbackIndex) {
|
|
4140
|
+
if (srvId.startsWith("srvtoolu_")) {
|
|
4141
|
+
const suffix = srvId.slice(9);
|
|
4142
|
+
if (/^[a-zA-Z0-9_]+$/.test(suffix)) return `toolu_${suffix}`;
|
|
4143
|
+
}
|
|
4144
|
+
return `toolu_advisor_${fallbackIndex}`;
|
|
4145
|
+
}
|
|
4146
|
+
/**
|
|
4147
|
+
* Fast-path detector: returns true if the raw body has any chance of
|
|
4148
|
+
* needing sanitization. Avoids a full JSON parse for the common case
|
|
4149
|
+
* where the body is already spec-compliant.
|
|
4150
|
+
*
|
|
4151
|
+
* Looks for either an Anthropic-native advisor typed tool entry, or
|
|
4152
|
+
* any advisor-related block type that would need rewriting/
|
|
4153
|
+
* translating.
|
|
4154
|
+
*/
|
|
4155
|
+
function bodyMightNeedSanitize(rawBody) {
|
|
4156
|
+
return rawBody.includes("\"server_tool_use\"") || rawBody.includes("\"advisor_tool_result\"") || /"type":"advisor_\d+"/.test(rawBody);
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Translate one assistant turn's content array, splitting at advisor
|
|
4160
|
+
* pairs into the multi-message structure Copilot accepts.
|
|
4161
|
+
*
|
|
4162
|
+
* Input shape (Claude Code stores everything in one assistant turn):
|
|
4163
|
+
* [text*, server_tool_use{advisor}, advisor_tool_result, text*, ...]
|
|
4164
|
+
*
|
|
4165
|
+
* Output: array of {role, content[]} message objects, alternating
|
|
4166
|
+
* assistant→user→assistant for each advisor pair encountered.
|
|
4167
|
+
*/
|
|
4168
|
+
function splitAssistantTurnAtAdvisorPairs(originalContent, syntheticIndexRef) {
|
|
4169
|
+
const messages = [];
|
|
4170
|
+
let currentAssistantContent = [];
|
|
4171
|
+
let translated = false;
|
|
4172
|
+
let i = 0;
|
|
4173
|
+
while (i < originalContent.length) {
|
|
4174
|
+
const block = originalContent[i];
|
|
4175
|
+
const b = typeof block === "object" && block !== null ? block : null;
|
|
4176
|
+
if (b && b.type === "server_tool_use" && b.name === ADVISOR_INTERNAL_TOOL_NAME.replace(/^__anthropic_/, "")) {
|
|
4177
|
+
const stuId = typeof b.id === "string" ? b.id : "";
|
|
4178
|
+
const nextBlock = originalContent[i + 1];
|
|
4179
|
+
const next = typeof nextBlock === "object" && nextBlock !== null ? nextBlock : null;
|
|
4180
|
+
const copilotId = stuId.startsWith("srvtoolu_") ? toCopilotToolUseId(stuId, syntheticIndexRef.value++) : stuId.startsWith("toolu_") && /^toolu_[a-zA-Z0-9_]+$/.test(stuId) ? stuId : `toolu_advisor_${syntheticIndexRef.value++}`;
|
|
4181
|
+
currentAssistantContent.push({
|
|
4182
|
+
type: "tool_use",
|
|
4183
|
+
id: copilotId,
|
|
4184
|
+
name: ADVISOR_INTERNAL_TOOL_NAME,
|
|
4185
|
+
input: {}
|
|
4186
|
+
});
|
|
4187
|
+
messages.push({
|
|
4188
|
+
role: "assistant",
|
|
4189
|
+
content: currentAssistantContent
|
|
4190
|
+
});
|
|
4191
|
+
translated = true;
|
|
4192
|
+
let resultText = "";
|
|
4193
|
+
if (next && next.type === "advisor_tool_result") {
|
|
4194
|
+
const c = next.content;
|
|
4195
|
+
if (typeof c === "string") resultText = c;
|
|
4196
|
+
else if (typeof c === "object" && c !== null) {
|
|
4197
|
+
const txt = c.text;
|
|
4198
|
+
if (typeof txt === "string") resultText = txt;
|
|
4199
|
+
}
|
|
4200
|
+
i += 2;
|
|
4201
|
+
} else {
|
|
4202
|
+
resultText = "[Advisor result missing in conversation history.]";
|
|
4203
|
+
i += 1;
|
|
4204
|
+
}
|
|
4205
|
+
messages.push({
|
|
4206
|
+
role: "user",
|
|
4207
|
+
content: [{
|
|
4208
|
+
type: "tool_result",
|
|
4209
|
+
tool_use_id: copilotId,
|
|
4210
|
+
content: resultText
|
|
4211
|
+
}]
|
|
4212
|
+
});
|
|
4213
|
+
currentAssistantContent = [];
|
|
4214
|
+
continue;
|
|
4215
|
+
}
|
|
4216
|
+
if (b && b.type === "advisor_tool_result") {
|
|
4217
|
+
translated = true;
|
|
4218
|
+
i += 1;
|
|
4219
|
+
continue;
|
|
4220
|
+
}
|
|
4221
|
+
currentAssistantContent.push(block);
|
|
4222
|
+
i += 1;
|
|
4223
|
+
}
|
|
4224
|
+
if (currentAssistantContent.length > 0) messages.push({
|
|
4225
|
+
role: "assistant",
|
|
4226
|
+
content: currentAssistantContent
|
|
4227
|
+
});
|
|
4228
|
+
if (!translated) return {
|
|
4229
|
+
messages: [{
|
|
4230
|
+
role: "assistant",
|
|
4231
|
+
content: originalContent
|
|
4232
|
+
}],
|
|
4233
|
+
translated: false
|
|
4234
|
+
};
|
|
4235
|
+
return {
|
|
4236
|
+
messages,
|
|
4237
|
+
translated: true
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
function sanitizeAnthropicBody(rawBody) {
|
|
4241
|
+
if (!bodyMightNeedSanitize(rawBody)) return rawBody;
|
|
4242
|
+
let parsed;
|
|
4243
|
+
try {
|
|
4244
|
+
parsed = JSON.parse(rawBody);
|
|
4245
|
+
} catch {
|
|
4246
|
+
return rawBody;
|
|
4247
|
+
}
|
|
4248
|
+
let mutated = false;
|
|
4249
|
+
if (Array.isArray(parsed.tools)) {
|
|
4250
|
+
const tools = parsed.tools;
|
|
4251
|
+
const before = tools.length;
|
|
4252
|
+
const filtered = tools.filter((t) => {
|
|
4253
|
+
if (typeof t !== "object" || t === null) return true;
|
|
4254
|
+
const type = t.type;
|
|
4255
|
+
return typeof type !== "string" || !type.startsWith("advisor_");
|
|
4256
|
+
});
|
|
4257
|
+
if (filtered.length !== before) {
|
|
4258
|
+
parsed.tools = filtered;
|
|
4259
|
+
mutated = true;
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
if (Array.isArray(parsed.messages)) {
|
|
4263
|
+
const original = parsed.messages;
|
|
4264
|
+
const rebuilt = [];
|
|
4265
|
+
let anyTranslated = false;
|
|
4266
|
+
const syntheticIndexRef = { value: 0 };
|
|
4267
|
+
for (const msg of original) {
|
|
4268
|
+
if (typeof msg !== "object" || msg === null || msg.role !== "assistant") {
|
|
4269
|
+
rebuilt.push(msg);
|
|
4270
|
+
continue;
|
|
4271
|
+
}
|
|
4272
|
+
const content = msg.content;
|
|
4273
|
+
if (!Array.isArray(content)) {
|
|
4274
|
+
rebuilt.push(msg);
|
|
4275
|
+
continue;
|
|
4276
|
+
}
|
|
4277
|
+
if (!content.some((b) => {
|
|
4278
|
+
if (typeof b !== "object" || b === null) return false;
|
|
4279
|
+
const type = b.type;
|
|
4280
|
+
const name$1 = b.name;
|
|
4281
|
+
return type === "server_tool_use" && name$1 === "advisor" || type === "advisor_tool_result";
|
|
4282
|
+
})) {
|
|
4283
|
+
rebuilt.push(msg);
|
|
4284
|
+
continue;
|
|
4285
|
+
}
|
|
4286
|
+
const { messages: split, translated } = splitAssistantTurnAtAdvisorPairs(content, syntheticIndexRef);
|
|
4287
|
+
if (translated) {
|
|
4288
|
+
anyTranslated = true;
|
|
4289
|
+
for (const m of split) rebuilt.push(m);
|
|
4290
|
+
} else rebuilt.push(msg);
|
|
4291
|
+
}
|
|
4292
|
+
if (anyTranslated) {
|
|
4293
|
+
parsed.messages = rebuilt;
|
|
4294
|
+
mutated = true;
|
|
4295
|
+
const existingTools = Array.isArray(parsed.tools) ? parsed.tools : [];
|
|
4296
|
+
if (!existingTools.some((t) => {
|
|
4297
|
+
if (typeof t !== "object" || t === null) return false;
|
|
4298
|
+
return t.name === ADVISOR_INTERNAL_TOOL_NAME;
|
|
4299
|
+
})) parsed.tools = [...existingTools, {
|
|
4300
|
+
name: ADVISOR_INTERNAL_TOOL_NAME,
|
|
4301
|
+
description: ADVISOR_TOOL_INSTRUCTIONS,
|
|
4302
|
+
input_schema: {
|
|
4303
|
+
type: "object",
|
|
4304
|
+
properties: {},
|
|
4305
|
+
required: []
|
|
4306
|
+
}
|
|
4307
|
+
}];
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
if (!mutated) return rawBody;
|
|
4311
|
+
return JSON.stringify(parsed);
|
|
4312
|
+
}
|
|
4313
|
+
|
|
3243
4314
|
//#endregion
|
|
3244
4315
|
//#region src/lib/diagnose-response.ts
|
|
3245
4316
|
const PREVIEW_LIMIT = 200;
|
|
@@ -3290,7 +4361,18 @@ function stripWebSearchFromBody(rawBody) {
|
|
|
3290
4361
|
*/
|
|
3291
4362
|
async function handleCountTokens(c) {
|
|
3292
4363
|
const startTime = Date.now();
|
|
3293
|
-
const
|
|
4364
|
+
const strippedBody = stripWebSearchFromBody(sanitizeAnthropicBody(await c.req.text()));
|
|
4365
|
+
if (strippedBody.includes("\"mcp_servers\"")) try {
|
|
4366
|
+
const probe = JSON.parse(strippedBody);
|
|
4367
|
+
if (Array.isArray(probe.mcp_servers) && probe.mcp_servers.length > 0) return c.json({
|
|
4368
|
+
type: "error",
|
|
4369
|
+
error: {
|
|
4370
|
+
type: "invalid_request_error",
|
|
4371
|
+
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."
|
|
4372
|
+
}
|
|
4373
|
+
}, 400);
|
|
4374
|
+
} catch {}
|
|
4375
|
+
const { body: finalBody, originalModel, resolvedModel } = resolveModelInBody$1(strippedBody);
|
|
3294
4376
|
const extraHeaders = {};
|
|
3295
4377
|
const anthropicBeta = c.req.header("anthropic-beta");
|
|
3296
4378
|
if (anthropicBeta) {
|
|
@@ -3334,6 +4416,7 @@ function resolveModelInBody$1(rawBody) {
|
|
|
3334
4416
|
}
|
|
3335
4417
|
}
|
|
3336
4418
|
if (rawBody.includes("\"scope\"") && sanitizeCacheControl$1(parsed)) modified = true;
|
|
4419
|
+
if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"")) && stripAnthropicOnlyFields$1(parsed)) modified = true;
|
|
3337
4420
|
const resolvedModel = typeof parsed.model === "string" ? parsed.model : originalModel;
|
|
3338
4421
|
return {
|
|
3339
4422
|
body: modified ? JSON.stringify(parsed) : rawBody,
|
|
@@ -3360,6 +4443,43 @@ function sanitizeCacheControl$1(body) {
|
|
|
3360
4443
|
if (Array.isArray(body.tools)) for (const tool of body.tools) stripScope(tool);
|
|
3361
4444
|
return stripped;
|
|
3362
4445
|
}
|
|
4446
|
+
/**
|
|
4447
|
+
* Strip top-level body fields Copilot 400s on (budget, output_config.schema,
|
|
4448
|
+
* betas). Duplicated structurally from handler.ts because count_tokens uses
|
|
4449
|
+
* its own JSON-pass; the bodies are independent. Behavior must stay in lock-
|
|
4450
|
+
* step with handler.ts's stripAnthropicOnlyFields — covered by integration
|
|
4451
|
+
* tests (Phase F P2.4).
|
|
4452
|
+
*/
|
|
4453
|
+
function stripAnthropicOnlyFields$1(body) {
|
|
4454
|
+
let stripped = false;
|
|
4455
|
+
if (body.budget !== void 0) {
|
|
4456
|
+
consola.warn("[count_tokens] Stripping body-level `budget` field (Copilot 400s)");
|
|
4457
|
+
delete body.budget;
|
|
4458
|
+
stripped = true;
|
|
4459
|
+
}
|
|
4460
|
+
if (body.output_config !== void 0) {
|
|
4461
|
+
if (body.output_config && typeof body.output_config === "object") {
|
|
4462
|
+
const oc = body.output_config;
|
|
4463
|
+
const PROXY_OWNED_FIELDS = new Set(["effort"]);
|
|
4464
|
+
let strippedAny = false;
|
|
4465
|
+
for (const key of Object.keys(oc)) if (!PROXY_OWNED_FIELDS.has(key)) {
|
|
4466
|
+
delete oc[key];
|
|
4467
|
+
strippedAny = true;
|
|
4468
|
+
}
|
|
4469
|
+
if (strippedAny) {
|
|
4470
|
+
consola.warn("[count_tokens] Stripping client-set `output_config` Structured-Outputs fields (Copilot 400s on `output_config.*` other than `effort`)");
|
|
4471
|
+
if (Object.keys(oc).length === 0) delete body.output_config;
|
|
4472
|
+
stripped = true;
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4476
|
+
if (Array.isArray(body.betas)) {
|
|
4477
|
+
consola.warn("[count_tokens] Stripping body-level `betas` array (Copilot 400s; conveyed via header)");
|
|
4478
|
+
delete body.betas;
|
|
4479
|
+
stripped = true;
|
|
4480
|
+
}
|
|
4481
|
+
return stripped;
|
|
4482
|
+
}
|
|
3363
4483
|
|
|
3364
4484
|
//#endregion
|
|
3365
4485
|
//#region src/routes/messages/handler.ts
|
|
@@ -3470,7 +4590,24 @@ async function handleCompletion(c) {
|
|
|
3470
4590
|
if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
|
|
3471
4591
|
if (state.manualApprove) await awaitApproval();
|
|
3472
4592
|
const betaHeaders = extractBetaHeaders(c);
|
|
3473
|
-
const
|
|
4593
|
+
const advisorEnabled = isAdvisorRequested(c.req.header("anthropic-beta"));
|
|
4594
|
+
let finalBody = await processWebSearch(rawBody);
|
|
4595
|
+
finalBody = sanitizeAnthropicBody(finalBody);
|
|
4596
|
+
if (advisorEnabled) {
|
|
4597
|
+
finalBody = injectAdvisorTool(finalBody);
|
|
4598
|
+
consola.info("ADVISOR enabled for this request — injecting __anthropic_advisor tool; will translate tool_use → server_tool_use{advisor} on the SSE stream");
|
|
4599
|
+
}
|
|
4600
|
+
if (finalBody.includes("\"mcp_servers\"")) try {
|
|
4601
|
+
const probe = JSON.parse(finalBody);
|
|
4602
|
+
if (Array.isArray(probe.mcp_servers) && probe.mcp_servers.length > 0) return c.json({
|
|
4603
|
+
type: "error",
|
|
4604
|
+
error: {
|
|
4605
|
+
type: "invalid_request_error",
|
|
4606
|
+
message: "Inline `mcp_servers` body field is not supported by github-router (Copilot returns 400 'Extra inputs are not permitted'; the proxy would need a multi-turn tool-loop translation that has unresolved design holes — see Phase G in the plan). Configure your 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)"
|
|
4607
|
+
}
|
|
4608
|
+
}, 400);
|
|
4609
|
+
} catch {}
|
|
4610
|
+
const { body: resolvedBody, originalModel, resolvedModel, selectedModel } = resolveModelInBody(finalBody);
|
|
3474
4611
|
const modelId = resolvedModel ?? originalModel;
|
|
3475
4612
|
if (modelId) logEndpointMismatch(modelId, "/v1/messages");
|
|
3476
4613
|
const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
|
|
@@ -3524,6 +4661,25 @@ async function handleCompletion(c) {
|
|
|
3524
4661
|
if (requestId) streamHeaders["x-request-id"] = requestId;
|
|
3525
4662
|
const reqId = response.headers.get("request-id");
|
|
3526
4663
|
if (reqId) streamHeaders["request-id"] = reqId;
|
|
4664
|
+
if (advisorEnabled && response.body) {
|
|
4665
|
+
let parsedBase = {};
|
|
4666
|
+
try {
|
|
4667
|
+
parsedBase = JSON.parse(resolvedBody);
|
|
4668
|
+
} catch {}
|
|
4669
|
+
const initialConversation = Array.isArray(parsedBase.messages) ? parsedBase.messages : [];
|
|
4670
|
+
return new Response(buildAdvisorStream({
|
|
4671
|
+
firstResponse: response,
|
|
4672
|
+
initialConversation,
|
|
4673
|
+
baseBody: parsedBase,
|
|
4674
|
+
requestHeaders: {
|
|
4675
|
+
...selectedModel?.requestHeaders,
|
|
4676
|
+
...effectiveBetas
|
|
4677
|
+
}
|
|
4678
|
+
}), {
|
|
4679
|
+
status: response.status,
|
|
4680
|
+
headers: streamHeaders
|
|
4681
|
+
});
|
|
4682
|
+
}
|
|
3527
4683
|
return new Response(response.body ? relayAnthropicStream(response.body, { routePath: c.req.path }) : null, {
|
|
3528
4684
|
status: response.status,
|
|
3529
4685
|
headers: streamHeaders
|
|
@@ -3574,6 +4730,7 @@ function resolveModelInBody(rawBody) {
|
|
|
3574
4730
|
const selectedModel = resolvedModel ? state.models?.data.find((m) => m.id === resolvedModel) : void 0;
|
|
3575
4731
|
if (translateThinking(parsed, selectedModel)) modified = true;
|
|
3576
4732
|
if (rawBody.includes("\"scope\"") && sanitizeCacheControl(parsed)) modified = true;
|
|
4733
|
+
if ((rawBody.includes("\"budget\"") || rawBody.includes("\"output_config\"") || rawBody.includes("\"betas\"")) && stripAnthropicOnlyFields(parsed)) modified = true;
|
|
3577
4734
|
return {
|
|
3578
4735
|
body: modified ? JSON.stringify(parsed) : rawBody,
|
|
3579
4736
|
originalModel,
|
|
@@ -3689,6 +4846,81 @@ function applyDefaultBetas(betaHeaders, modelId) {
|
|
|
3689
4846
|
"anthropic-beta": ["interleaved-thinking-2025-05-14", "context-management-2025-06-27"].join(",")
|
|
3690
4847
|
};
|
|
3691
4848
|
}
|
|
4849
|
+
/**
|
|
4850
|
+
* Strip top-level body fields that Anthropic's Messages API accepts but
|
|
4851
|
+
* Copilot rejects with HTTP 400 "Extra inputs are not permitted". Mutates
|
|
4852
|
+
* `body` in place; returns true if anything was stripped.
|
|
4853
|
+
*
|
|
4854
|
+
* Empirical verification (2026-05-11):
|
|
4855
|
+
* POST /v1/messages?beta=true { ..., budget: {total_tokens: 10000} } → 400
|
|
4856
|
+
* POST /v1/messages?beta=true { ..., output_config: {schema: {...}} } → 400
|
|
4857
|
+
* POST /v1/messages?beta=true { ..., betas: ["..."] } → 400
|
|
4858
|
+
*
|
|
4859
|
+
* Each strip emits a one-line consola.warn so users running with these
|
|
4860
|
+
* features (e.g. `claude --max-budget-usd`, `--json-schema`) understand
|
|
4861
|
+
* the request succeeds with the *body field* dropped — semantics may
|
|
4862
|
+
* differ from upstream Anthropic. The corresponding `anthropic-beta`
|
|
4863
|
+
* header is preserved (Phase A allowlist) so the *intent* still flows
|
|
4864
|
+
* to Copilot, even if the per-request enforcement field is gone.
|
|
4865
|
+
*
|
|
4866
|
+
* NOT stripped here:
|
|
4867
|
+
* - `mcp_servers` (Phase G translate path — silent strip causes LLM
|
|
4868
|
+
* to hallucinate tools per gemini-critic finding)
|
|
4869
|
+
* - `metadata` (Copilot 200s, ignores harmlessly)
|
|
4870
|
+
*/
|
|
4871
|
+
function stripAnthropicOnlyFields(body) {
|
|
4872
|
+
let stripped = false;
|
|
4873
|
+
if (body.budget !== void 0) {
|
|
4874
|
+
consola.warn("Stripping body-level `budget` field (Copilot 400s; the `task-budgets-` beta header is preserved but cost ceiling is not enforced server-side)");
|
|
4875
|
+
delete body.budget;
|
|
4876
|
+
stripped = true;
|
|
4877
|
+
}
|
|
4878
|
+
if (body.output_config !== void 0) {
|
|
4879
|
+
if (body.output_config && typeof body.output_config === "object") {
|
|
4880
|
+
const oc = body.output_config;
|
|
4881
|
+
const PROXY_OWNED_FIELDS = new Set(["effort"]);
|
|
4882
|
+
const schema = oc.schema;
|
|
4883
|
+
const ocType = oc.type;
|
|
4884
|
+
let strippedAny = false;
|
|
4885
|
+
for (const key of Object.keys(oc)) if (!PROXY_OWNED_FIELDS.has(key)) {
|
|
4886
|
+
delete oc[key];
|
|
4887
|
+
strippedAny = true;
|
|
4888
|
+
}
|
|
4889
|
+
if (strippedAny) {
|
|
4890
|
+
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)");
|
|
4891
|
+
if (Object.keys(oc).length === 0) delete body.output_config;
|
|
4892
|
+
if (schema !== void 0 || ocType === "json_object") appendStructuredOutputInstruction(body, schema, ocType);
|
|
4893
|
+
stripped = true;
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
if (Array.isArray(body.betas)) {
|
|
4898
|
+
consola.warn("Stripping body-level `betas` array (Copilot 400s; the betas are conveyed via the `anthropic-beta` header instead)");
|
|
4899
|
+
delete body.betas;
|
|
4900
|
+
stripped = true;
|
|
4901
|
+
}
|
|
4902
|
+
return stripped;
|
|
4903
|
+
}
|
|
4904
|
+
/**
|
|
4905
|
+
* Append a system-prompt instruction telling the model to produce JSON
|
|
4906
|
+
* conforming to a Structured Outputs schema. Used after the proxy
|
|
4907
|
+
* strips `output_config` to preserve the schema enforcement intent
|
|
4908
|
+
* via prompt engineering instead of server-side validation.
|
|
4909
|
+
*
|
|
4910
|
+
* Mutates `body.system` in place. Handles both string and array shapes
|
|
4911
|
+
* (Anthropic spec allows either).
|
|
4912
|
+
*/
|
|
4913
|
+
function appendStructuredOutputInstruction(body, schema, ocType) {
|
|
4914
|
+
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.";
|
|
4915
|
+
if (schema !== void 0) instruction += ` The JSON object MUST conform to this JSON Schema:\n${JSON.stringify(schema)}`;
|
|
4916
|
+
else if (typeof ocType === "string") instruction += ` Output type requested: ${ocType}.`;
|
|
4917
|
+
if (typeof body.system === "string") body.system = body.system + instruction;
|
|
4918
|
+
else if (Array.isArray(body.system)) body.system = [...body.system, {
|
|
4919
|
+
type: "text",
|
|
4920
|
+
text: instruction.trimStart()
|
|
4921
|
+
}];
|
|
4922
|
+
else body.system = instruction.trimStart();
|
|
4923
|
+
}
|
|
3692
4924
|
|
|
3693
4925
|
//#endregion
|
|
3694
4926
|
//#region src/routes/messages/route.ts
|
|
@@ -4108,6 +5340,13 @@ server.route("/v1/search", searchRoutes);
|
|
|
4108
5340
|
server.route("/v1/messages", messageRoutes);
|
|
4109
5341
|
server.route("/mcp", mcpRoutes);
|
|
4110
5342
|
server.post("/api/event_logging/batch", (c) => c.body(null, 200));
|
|
5343
|
+
server.all("/v1/files/*", (c) => c.json({
|
|
5344
|
+
type: "error",
|
|
5345
|
+
error: {
|
|
5346
|
+
type: "not_found_error",
|
|
5347
|
+
message: "Files API is not supported by github-router (Copilot has no equivalent storage backend). Use the Anthropic API directly for file uploads/downloads."
|
|
5348
|
+
}
|
|
5349
|
+
}, 404));
|
|
4111
5350
|
server.notFound((c) => c.json({
|
|
4112
5351
|
type: "error",
|
|
4113
5352
|
error: {
|
|
@@ -4318,9 +5557,17 @@ function getClaudeCodeEnvVars(serverUrl, model) {
|
|
|
4318
5557
|
CLAUDE_CONFIG_DIR: path.join(os.homedir(), ".claude"),
|
|
4319
5558
|
MCP_TIMEOUT: "600000",
|
|
4320
5559
|
DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
|
|
4321
|
-
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
|
5560
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
5561
|
+
DISABLE_TELEMETRY: "1"
|
|
4322
5562
|
};
|
|
4323
5563
|
if (model) vars.ANTHROPIC_MODEL = model;
|
|
5564
|
+
for (const key of [
|
|
5565
|
+
"CLAUDE_CODE_ENABLE_EXPERIMENTAL_ADVISOR_TOOL",
|
|
5566
|
+
"CLAUDE_CODE_FORK_SUBAGENT",
|
|
5567
|
+
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
|
|
5568
|
+
"CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING",
|
|
5569
|
+
"CLAUDE_CODE_ENABLE_TASKS"
|
|
5570
|
+
]) if (process.env[key] === void 0) vars[key] = "1";
|
|
4324
5571
|
return vars;
|
|
4325
5572
|
}
|
|
4326
5573
|
/**
|
|
@@ -4371,6 +5618,21 @@ const claude = defineCommand({
|
|
|
4371
5618
|
type: "boolean",
|
|
4372
5619
|
default: false,
|
|
4373
5620
|
description: "Pass --strict-mcp-config to claude code so only github-router's MCP servers are loaded (hides user's existing MCP servers)"
|
|
5621
|
+
},
|
|
5622
|
+
stealth: {
|
|
5623
|
+
type: "boolean",
|
|
5624
|
+
default: false,
|
|
5625
|
+
description: "Opt back into VS Code-only beta header filtering. Loses leverage features (task budgets, token-efficient tools, prompt caching, etc.) but minimizes the wire-fingerprint difference from VS Code Copilot Chat. By default the `claude` subcommand enables extended/leverage betas because the spawned Claude Code already identifies itself via UA and other headers — partial stealth doesn't buy much."
|
|
5626
|
+
},
|
|
5627
|
+
"auto-update": {
|
|
5628
|
+
type: "boolean",
|
|
5629
|
+
default: true,
|
|
5630
|
+
description: "Check for and install latest Claude Code on launch (throttled to once per hour via ~/.local/share/github-router/last-update-check). Set to false (--no-auto-update) to keep the current installed version. Falls back gracefully if npm/network unavailable."
|
|
5631
|
+
},
|
|
5632
|
+
"update-check": {
|
|
5633
|
+
type: "boolean",
|
|
5634
|
+
default: true,
|
|
5635
|
+
description: "Check the npm registry for a newer Claude Code version on launch and warn if stale (non-blocking ~500ms cost). Set to false (--no-update-check) to skip the check entirely (useful for offline/CI). Independent from --auto-update: --no-update-check implies no auto-install (nothing to install since we never check)."
|
|
4374
5636
|
}
|
|
4375
5637
|
},
|
|
4376
5638
|
async run({ args }) {
|
|
@@ -4379,6 +5641,24 @@ const claude = defineCommand({
|
|
|
4379
5641
|
process$1.exit(1);
|
|
4380
5642
|
}
|
|
4381
5643
|
const parsed = parseSharedArgs(args);
|
|
5644
|
+
if (args.stealth) {
|
|
5645
|
+
parsed.extendedBetas = false;
|
|
5646
|
+
consola.info("Stealth mode: VS Code-only beta filtering. Leverage features disabled.");
|
|
5647
|
+
} else if (!args["extended-betas"]) parsed.extendedBetas = true;
|
|
5648
|
+
if (args["update-check"] !== false) try {
|
|
5649
|
+
const versionCheck = await checkClaudeVersion({ noCheck: false });
|
|
5650
|
+
if (versionCheck.skipped && versionCheck.skipReason === "no-claude") consola.debug("claude --version probe failed; skipping auto-update.");
|
|
5651
|
+
else if (versionCheck.skipped && versionCheck.skipReason === "no-npm") consola.debug("npm view @anthropic-ai/claude-code failed; skipping auto-update check (likely offline).");
|
|
5652
|
+
else if (versionCheck.needsUpdate && versionCheck.installedVersion && versionCheck.latestVersion) if (args["auto-update"] !== false) try {
|
|
5653
|
+
await autoUpdateClaude(versionCheck.latestVersion);
|
|
5654
|
+
} catch (err) {
|
|
5655
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5656
|
+
consola.warn(`Auto-update of Claude Code from ${versionCheck.installedVersion} to ${versionCheck.latestVersion} failed (${msg}); continuing with installed version. Run \`npm install -g @anthropic-ai/claude-code@latest\` manually to retry.`);
|
|
5657
|
+
}
|
|
5658
|
+
else consola.warn(`Claude Code v${versionCheck.installedVersion} is installed; v${versionCheck.latestVersion} is available. Run with --auto-update (the default) to install on launch, or \`npm install -g @anthropic-ai/claude-code@latest\` manually.`);
|
|
5659
|
+
} catch (err) {
|
|
5660
|
+
consola.debug("Claude version check failed:", err);
|
|
5661
|
+
}
|
|
4382
5662
|
let server$1;
|
|
4383
5663
|
let serverUrl;
|
|
4384
5664
|
try {
|