github-router 0.3.153 → 0.3.162

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 CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { $ as logStreamError, $t as GITHUB_API_BASE_URL, At as DEFAULT_PORT, B as toolbeltEnabled, Bt as tryRefreshAndRetry, C as repoFingerprint, Ct as shouldUseInsecureTls, D as trustRepo, Dt as DEFAULT_CLAUDE_MODEL_FALLBACKS, E as stopReviewStateDir, Et as toolbeltPathOverride, Ft as getPackageVersion, G as searchWeb, Gt as isNullish, H as vscodeRipgrepPath, Ht as cacheModels, It as withInstallLock, J as buildAdvisorStream, Jt as sleep, K as ADVISOR_INTERNAL_TOOL_NAME, Kt as resolveCodexModel, Lt as setupCopilotToken, Mt as UPSTREAM_INACTIVITY_TIMEOUT_MS, Nt as generateRandomPort, O as resolveSealedGate, Ot as DEFAULT_CODEX_MODEL, Pt as pickClaudeDefault, Q as isControllerClosedError, Qt as forwardError, R as availableToolCommands, Rt as setupGitHubAgentToken, S as isSubagentContext, St as extractZipMember, T as stopGateEnabledForRepo, Tt as collapsePathKeys, U as TOOLBELT_TOOLS, Ut as cacheVSCodeVersion, V as toolbeltSkipSet, Vt as cacheCopilotVersion, W as assetFor, Wt as filterBetaHeader, X as isAdvisorRequested, Xt as fetchWithTransientRetry, Y as injectAdvisorTool, Yt as getModels, Z as buildOpenAIErrorEvent, Zt as HTTPError, _ as stopReviewEnabled, _t as parseJsonOrDiagnose, a as buildPeerAwarenessSnippet, at as browseAgentEnabled, b as fileLastPromptStore, bt as provisionAndIndexColbert, c as buildSessionBindHookCommand, ct as standInToolEnabled, d as decideStopHook, dt as createMessages, en as copilotBaseUrl, et as readIteratorWithTimeout, f as fileBlockBudget, ft as getTokenCount, g as stopGateId, gt as readResponseBodyCapped, h as stopGateDisabled, ht as MAX_RESPONSE_BODY_BYTES, i as buildAgentPrompt, it as agentToolsEnabled, jt as UPSTREAM_FETCH_TIMEOUT_MS, k as liveExec, kt as DEFAULT_CODEX_MODEL_FALLBACKS, l as buildStopHookCommand, lt as workerToolsEnabled, m as launchBaselineKey, mt as createChatCompletions, n as MCP_GROUPS, nn as githubHeaders, nt as handleMcpDelete, o as personasFor, ot as browserToolsEnabled, p as injectStopHookIntoSettingsFile, pt as createResponses, q as ADVISOR_TOOL_INSTRUCTIONS, qt as resolveModel, r as assertMcpToolSurfaceConsistent, rn as state, rt as handleMcpPost, s as buildArtifactOpenHookCommand, st as fleetToolsEnabled, t as GROUP_META, tn as copilotHeaders, tt as relayAnthropicStream, u as captureLaunchBaseline, ut as countTokens, v as fileBaselineStore, vt as provisionBrowserAssets, w as repoRoot, wt as ArtifactClient, x as fileReviewDebounce, xt as extractTarGzMember, y as fileFindingsStore, yt as hasSupportedBrowserInstalled, z as buildToolbeltAwareness, zt as setupGitHubToken } from "./peer-mcp-personas-DMM1akDa.js";
3
- import { a as removeOwnClaudeConfigMirror, i as isUnderClaudeConfigMirror, l as writeArtifactCredsToMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS, u as writeRuntimeFileSecure } from "./paths-Bt7sqiVr.js";
4
- import { c as killManagedTree, d as runCommandCapture, f as runCommandVoid, l as parseBoolEnv, s as killChildProcessTree, u as resolveExecutable } from "./lifecycle-VTQI28wT.js";
5
- import { a as sweepRegistry } from "./lifecycle-C4k0pEvn.js";
2
+ import { $ as buildOpenAIErrorEvent, $t as resolveModel, At as ArtifactClient, B as buildToolbeltAwareness, Bt as pickClaudeDefault, C as repoFingerprint, Ct as parseJsonOrDiagnose, D as trustRepo, Dt as extractTarGzMember, E as stopReviewStateDir, Et as provisionAndIndexColbert, Ft as DEFAULT_CODEX_MODEL_FALLBACKS, G as assetFor, Gt as setupGitHubToken, H as toolbeltSkipSet, Ht as withInstallLock, It as DEFAULT_PORT, J as ADVISOR_TOOL_INSTRUCTIONS, Jt as cacheModels, K as searchWeb, Kt as tryRefreshAndRetry, Lt as UPSTREAM_FETCH_TIMEOUT_MS, Mt as toolbeltPathOverride, Nt as DEFAULT_CLAUDE_MODEL_FALLBACKS, O as resolveSealedGate, Ot as extractZipMember, Pt as DEFAULT_CODEX_MODEL, Q as buildAnthropicErrorEvent, Qt as resolveCodexModel, Rt as UPSTREAM_INACTIVITY_TIMEOUT_MS, S as isSubagentContext, St as readResponseBodyCapped, T as stopGateEnabledForRepo, Tt as hasSupportedBrowserInstalled, U as vscodeRipgrepPath, Ut as setupCopilotToken, V as toolbeltEnabled, Vt as getPackageVersion, W as TOOLBELT_TOOLS, Wt as setupGitHubAgentToken, X as injectAdvisorTool, Xt as filterBetaHeader, Y as buildAdvisorStream, Yt as cacheVSCodeVersion, Z as isAdvisorRequested, Zt as isNullish, _ as stopReviewEnabled, _t as resolveMcpToolTimeoutMs, a as buildPeerAwarenessSnippet, an as GITHUB_API_BASE_URL, at as handleMcpPost, b as fileLastPromptStore, bt as createChatCompletions, c as buildSessionBindHookCommand, cn as githubHeaders, ct as browserToolsEnabled, d as decideStopHook, dt as standInToolEnabled, en as sleep, et as isControllerClosedError, f as fileBlockBudget, ft as workerToolsEnabled, g as stopGateId, gt as assembleResponsesPayload, h as stopGateDisabled, ht as getTokenCount, i as buildAgentPrompt, in as forwardError, it as handleMcpDelete, jt as collapsePathKeys, k as liveExec, kt as shouldUseInsecureTls, l as buildStopHookCommand, ln as state, lt as fleetToolsEnabled, m as launchBaselineKey, mt as createMessages, n as MCP_GROUPS, nn as fetchWithTransientRetry, nt as readIteratorWithTimeout, o as personasFor, on as copilotBaseUrl, ot as agentToolsEnabled, p as injectStopHookIntoSettingsFile, pt as countTokens, q as ADVISOR_INTERNAL_TOOL_NAME, qt as cacheCopilotVersion, r as assertMcpToolSurfaceConsistent, rn as HTTPError, rt as relayAnthropicStream, s as buildArtifactOpenHookCommand, sn as copilotHeaders, st as browseAgentEnabled, t as GROUP_META, tn as getModels, tt as logStreamError, u as captureLaunchBaseline, ut as implementerSubagentModel, v as fileBaselineStore, vt as pickEndpoint, w as repoRoot, wt as provisionBrowserAssets, x as fileReviewDebounce, xt as MAX_RESPONSE_BODY_BYTES, y as fileFindingsStore, yt as createResponses, z as availableToolCommands, zt as generateRandomPort } from "./peer-mcp-personas-Bfq3FPKc.js";
3
+ import { a as removeOwnClaudeConfigMirror, i as isUnderClaudeConfigMirror, l as writeArtifactCredsToMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS, u as writeRuntimeFileSecure } from "./paths-CTlT1nTo.js";
4
+ import { c as killManagedTree, d as runCommandCapture, f as runCommandVoid, l as parseBoolEnv, s as killChildProcessTree, u as resolveExecutable } from "./lifecycle-ChPBRt6K.js";
5
+ import { a as sweepRegistry } from "./lifecycle-BUXxiltc.js";
6
6
  import { defineCommand, runMain } from "citty";
7
7
  import consola from "consola";
8
8
  import { createHash, randomBytes, randomUUID } from "node:crypto";
9
9
  import fs, { chmod, copyFile, link, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
10
10
  import os, { homedir, tmpdir } from "node:os";
11
+ import * as nodePath$1 from "node:path";
11
12
  import nodePath from "node:path";
12
13
  import process$1 from "node:process";
13
14
  import { execFileSync, spawn } from "node:child_process";
15
+ import * as fs$2 from "node:fs";
14
16
  import fs$1, { existsSync, mkdirSync, promises, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
17
  import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
16
18
  import { Writable } from "node:stream";
@@ -815,7 +817,7 @@ function launchChild(target, server$1, options = {}) {
815
817
  * Frozen contract for the NON-BLOCKING workers surface.
816
818
  *
817
819
  * The `workers` MCP tools (`explore`/`implement`/`review`/`plan`/`test`, and
818
- * `browse` when the browse agent is enabled) BLOCK the caller for up to 30 min
820
+ * `browse` when the browse agent is enabled) BLOCK the caller for up to 6h
819
821
  * (`runWorkerAgent`). The MAIN Claude Code agent must never block on one, so a
820
822
  * per-mode `worker-*` DISPATCHER SUBAGENT — which Claude Code runs in the
821
823
  * background and reports on via a completion notification — is the only
@@ -971,14 +973,15 @@ function dispatcherPrompt(mode, workersKey) {
971
973
  `# Subagent: ${dispatcherAgentName(mode)}`,
972
974
  "",
973
975
  `You are a thin DISPATCHER for the \`${mode}\` worker. You run in the background so the`,
974
- "lead agent's turn is never blocked while the (up-to-30-minute) worker runs.",
976
+ "lead agent's turn is never blocked while the (up-to-6-hour) worker runs.",
975
977
  "",
976
978
  "## Your only job",
977
979
  "",
978
980
  `Call the \`${tool}\` tool EXACTLY ONCE, passing through the fields from the lead's brief:`,
979
981
  " - `prompt`: the lead's worker brief, copied verbatim",
980
982
  " - `workspace` (optional): absolute path, if the lead specified one",
981
- " - `model` / `thinking` (optional): only if the lead specified them" + (mode === "implement" || mode === "test" ? "\n - `worktree` (optional): pass `true` if the lead asked for isolated-worktree execution" : ""),
983
+ " - `model` / `thinking` (optional): only if the lead specified them",
984
+ " - `maxWallClockMs` (optional): per-call wall-clock budget in ms, if the lead specified one" + (mode === "implement" || mode === "test" ? "\n - `worktree` (optional): pass `true` if the lead asked for isolated-worktree execution" : ""),
982
985
  "",
983
986
  "When the tool returns, output its result VERBATIM as your final message. That final",
984
987
  "message is what the lead receives in the completion notification — it IS the result.",
@@ -1199,6 +1202,11 @@ function buildPeerAgentDefinitions(opts) {
1199
1202
  codexCli: opts.codexCli,
1200
1203
  geminiAvailable: opts.geminiAvailable
1201
1204
  });
1205
+ if (opts.implementerModel && opts.implementerModel.length > 0) out.implementer = {
1206
+ description: "Bounded implementation subagent running gpt-5.5 (strong non-Claude coder, high reasoning). Use for well-scoped coding tasks — edits, small features, fixes — you want implemented in an integrated subagent. Model is overridable at spawn.",
1207
+ prompt: "You are a bounded implementation subagent for well-scoped coding tasks. Implement the requested change surgically, matching the surrounding code style and minimizing unrelated churn. Verify with the project's build or tests where applicable. Do the work yourself — do not spawn further subagents. Report exactly what changed and any risks.",
1208
+ model: opts.implementerModel
1209
+ };
1202
1210
  if (opts.workerToolsAvailable) {
1203
1211
  const workersKey = workersKeyOf(opts.groupKeys);
1204
1212
  for (const mode of activeDispatchModes({ browse: opts.browseAvailable === true })) out[dispatcherAgentName(mode)] = {
@@ -1265,6 +1273,7 @@ function buildAgentMd(spec) {
1265
1273
  `name: ${spec.name}`,
1266
1274
  `description: ${escapeYamlString(spec.description)}`
1267
1275
  ];
1276
+ if (spec.model) lines.push(`model: ${escapeYamlString(spec.model)}`);
1268
1277
  if (spec.tools && spec.tools.length > 0) lines.push(`tools: [${spec.tools.map((t) => JSON.stringify(t)).join(", ")}]`);
1269
1278
  lines.push("---", "", spec.prompt, "");
1270
1279
  return lines.join("\n");
@@ -1299,6 +1308,7 @@ async function writePeerAgentMdFiles(agents, opts) {
1299
1308
  name: name$1,
1300
1309
  description: def.description,
1301
1310
  prompt: def.prompt,
1311
+ model: def.model,
1302
1312
  tools: def.tools
1303
1313
  }));
1304
1314
  paths.push(filePath);
@@ -1493,6 +1503,7 @@ async function writePeerMcpRuntimeFiles(serverUrl, opts) {
1493
1503
  groupKeys: opts.groupKeys,
1494
1504
  workerToolsAvailable: opts.workerToolsAvailable,
1495
1505
  browseAvailable: opts.browseAvailable,
1506
+ implementerModel: opts.implementerModel,
1496
1507
  nonce,
1497
1508
  codexHome
1498
1509
  });
@@ -2278,7 +2289,7 @@ async function discoverGateCommands(cwd, opts) {
2278
2289
  if (files.length === 0) return null;
2279
2290
  let result;
2280
2291
  try {
2281
- const { runWorkerAgent } = await import("./engine-BP5EnZ-n.js");
2292
+ const { runWorkerAgent } = await import("./engine-DHFa2_hK.js");
2282
2293
  result = await runWorkerAgent({
2283
2294
  mode: "explore",
2284
2295
  workspace: root,
@@ -2978,14 +2989,14 @@ const WORKER_SKILL = {
2978
2989
  name: "gh-worker",
2979
2990
  md: `---
2980
2991
  name: gh-worker
2981
- description: How to run github-router workers without blocking your turn. Workers (explore/implement/review/plan/test) can run up to 30 minutes; dispatch the matching worker-* background subagent so you get a completion notification instead of waiting. Use whenever you would reach for a worker.
2992
+ description: How to run github-router workers without blocking your turn. Workers (explore/implement/review/plan/test) can run up to 6 hours; dispatch the matching worker-* background subagent so you get a completion notification instead of waiting. Use whenever you would reach for a worker.
2982
2993
  user-invocable: true
2983
2994
  ---
2984
2995
 
2985
2996
  # gh-worker: non-blocking workers
2986
2997
 
2987
- Worker tasks (explore, implement, review, plan, test) can run for up to 30
2988
- minutes. In this session they are NON-BLOCKING BY DESIGN: you dispatch a
2998
+ Worker tasks (explore, implement, review, plan, test) can run for up to 6 hours.
2999
+ In this session they are NON-BLOCKING BY DESIGN: you dispatch a
2989
3000
  background \`worker-*\` subagent, get control back immediately, and receive the
2990
3001
  worker's result as a completion notification when it finishes. Your turn is
2991
3002
  never blocked waiting on a worker, and the worker's tool output never fills your
@@ -4084,7 +4095,7 @@ function initProxyFromEnv() {
4084
4095
  //#endregion
4085
4096
  //#region package.json
4086
4097
  var name = "github-router";
4087
- var version$1 = "0.3.153";
4098
+ var version$1 = "0.3.162";
4088
4099
 
4089
4100
  //#endregion
4090
4101
  //#region src/lib/approval.ts
@@ -4681,144 +4692,1554 @@ function sanitizeAnthropicBody(rawBody) {
4681
4692
  }
4682
4693
 
4683
4694
  //#endregion
4684
- //#region src/routes/messages/handler.ts
4685
- const isWebSearchTool$1 = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
4686
- /**
4687
- * Extract whitelisted beta headers from the incoming request to forward
4688
- * to the Copilot API. VS Code sends these to enable extended features
4689
- * like thinking, context management, and advanced tool use.
4690
- */
4691
- function extractBetaHeaders(c) {
4692
- const headers = {};
4693
- const anthropicBeta = c.req.header("anthropic-beta");
4694
- if (anthropicBeta) {
4695
- const filtered = filterBetaHeader(anthropicBeta);
4696
- if (filtered) headers["anthropic-beta"] = filtered;
4697
- }
4698
- return headers;
4695
+ //#region src/lib/anthropic-translate/anthropic-sse.ts
4696
+ function makeMessageId() {
4697
+ return `msg_${randomUUID().replace(/-/g, "")}`;
4698
+ }
4699
+ function makeMessageStart(id, model, usage = {}) {
4700
+ return {
4701
+ type: "message_start",
4702
+ message: {
4703
+ id,
4704
+ type: "message",
4705
+ role: "assistant",
4706
+ model,
4707
+ content: [],
4708
+ stop_reason: null,
4709
+ stop_sequence: null,
4710
+ usage: {
4711
+ input_tokens: usage.input_tokens ?? 0,
4712
+ output_tokens: usage.output_tokens ?? 0,
4713
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
4714
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
4715
+ }
4716
+ }
4717
+ };
4718
+ }
4719
+ function makeContentBlockStart(index, block) {
4720
+ return {
4721
+ type: "content_block_start",
4722
+ index,
4723
+ content_block: block
4724
+ };
4725
+ }
4726
+ function makeTextDelta(index, text) {
4727
+ return {
4728
+ type: "content_block_delta",
4729
+ index,
4730
+ delta: {
4731
+ type: "text_delta",
4732
+ text
4733
+ }
4734
+ };
4735
+ }
4736
+ function makeInputJsonDelta(index, partialJson) {
4737
+ return {
4738
+ type: "content_block_delta",
4739
+ index,
4740
+ delta: {
4741
+ type: "input_json_delta",
4742
+ partial_json: partialJson
4743
+ }
4744
+ };
4745
+ }
4746
+ function makeThinkingDelta(index, thinking) {
4747
+ return {
4748
+ type: "content_block_delta",
4749
+ index,
4750
+ delta: {
4751
+ type: "thinking_delta",
4752
+ thinking
4753
+ }
4754
+ };
4755
+ }
4756
+ function makeSignatureDelta(index, signature) {
4757
+ return {
4758
+ type: "content_block_delta",
4759
+ index,
4760
+ delta: {
4761
+ type: "signature_delta",
4762
+ signature
4763
+ }
4764
+ };
4765
+ }
4766
+ function makeContentBlockStop(index) {
4767
+ return {
4768
+ type: "content_block_stop",
4769
+ index
4770
+ };
4771
+ }
4772
+ function makeMessageDelta(stopReason, stopSequence, usage) {
4773
+ return {
4774
+ type: "message_delta",
4775
+ delta: {
4776
+ stop_reason: stopReason,
4777
+ stop_sequence: stopSequence
4778
+ },
4779
+ usage: {
4780
+ input_tokens: usage.input_tokens ?? 0,
4781
+ output_tokens: usage.output_tokens ?? 0,
4782
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
4783
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
4784
+ }
4785
+ };
4786
+ }
4787
+ function makeMessageStop() {
4788
+ return { type: "message_stop" };
4789
+ }
4790
+ /** Serialize one event to the Anthropic SSE wire form (`event:` + `data:`). */
4791
+ function serializeAnthropicEvent(ev) {
4792
+ return `event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`;
4699
4793
  }
4700
4794
  /**
4701
- * Extract the text content from the last user message for web search.
4702
- * Handles both string content and content block arrays (multimodal).
4795
+ * Wrap a generator of Anthropic stream events into a byte `ReadableStream`.
4796
+ * Pull-based; guards the consumer-cancel race on both `enqueue` and `close`;
4797
+ * converts a mid-stream generator throw into a terminal `event: error` frame.
4798
+ * On consumer cancel it invokes `onCancel` (abort the upstream fetch) and
4799
+ * `return()`s the generator so its `finally` tears down the upstream reader.
4703
4800
  */
4704
- function extractUserQuery$1(messages) {
4705
- for (let i = messages.length - 1; i >= 0; i--) {
4706
- const msg = messages[i];
4707
- if (msg.role === "user") {
4708
- if (typeof msg.content === "string") return msg.content;
4709
- if (Array.isArray(msg.content)) {
4710
- const textBlock = msg.content.find((block) => block.type === "text");
4711
- if (textBlock?.text) return textBlock.text;
4801
+ function anthropicSseStreamFromEvents(events, opts) {
4802
+ const enc = new TextEncoder();
4803
+ let consumerCancelled = false;
4804
+ let finished = false;
4805
+ const safeClose = (controller) => {
4806
+ try {
4807
+ controller.close();
4808
+ } catch {}
4809
+ };
4810
+ return new ReadableStream({
4811
+ async pull(controller) {
4812
+ if (consumerCancelled || finished) {
4813
+ safeClose(controller);
4814
+ return;
4815
+ }
4816
+ let res;
4817
+ try {
4818
+ res = await events.next();
4819
+ } catch (err) {
4820
+ finished = true;
4821
+ if (consumerCancelled) {
4822
+ safeClose(controller);
4823
+ return;
4824
+ }
4825
+ const name$1 = err instanceof Error ? err.name : "Error";
4826
+ const message = err instanceof Error ? err.message : String(err);
4827
+ consola.error(`Anthropic-translate stream interrupted at ${opts.routePath}: ${name$1}: ${JSON.stringify(message)}`);
4828
+ try {
4829
+ controller.enqueue(enc.encode(buildAnthropicErrorEvent(name$1, message)));
4830
+ } catch (enqueueError) {
4831
+ if (!isControllerClosedError(enqueueError)) consola.warn(`Could not deliver error event to consumer at ${opts.routePath}: ${enqueueError instanceof Error ? enqueueError.message : String(enqueueError)}`);
4832
+ }
4833
+ safeClose(controller);
4834
+ return;
4835
+ }
4836
+ if (consumerCancelled) {
4837
+ safeClose(controller);
4838
+ return;
4839
+ }
4840
+ if (res.done) {
4841
+ finished = true;
4842
+ safeClose(controller);
4843
+ return;
4844
+ }
4845
+ try {
4846
+ controller.enqueue(enc.encode(serializeAnthropicEvent(res.value)));
4847
+ } catch (err) {
4848
+ if (isControllerClosedError(err)) {
4849
+ consumerCancelled = true;
4850
+ return;
4851
+ }
4852
+ throw err;
4712
4853
  }
4854
+ },
4855
+ cancel() {
4856
+ consumerCancelled = true;
4857
+ finished = true;
4858
+ opts.onCancel?.();
4859
+ events.return?.(void 0);
4713
4860
  }
4714
- }
4861
+ });
4715
4862
  }
4863
+
4864
+ //#endregion
4865
+ //#region src/lib/reasoning-effort.ts
4716
4866
  /**
4717
- * Check if any user message contains tool_result content blocks,
4718
- * indicating a follow-up turn where we should skip web search.
4719
- * In Anthropic format, tool results are content blocks inside user messages,
4720
- * NOT separate role: "tool" messages like in OpenAI format.
4867
+ * Reasoning-effort bucketing + clamping shared by the `/v1/messages` handler
4868
+ * (adaptive-thinking translation) and the Anthropic-translation shim
4869
+ * (thinking-budget Responses `reasoning.effort`).
4870
+ *
4871
+ * Extracted from `src/routes/messages/handler.ts` so a `~/lib/*` module can
4872
+ * depend on it without importing route code (and without forming a
4873
+ * handler → shim → handler import cycle). `handler.ts` re-exports these for
4874
+ * backward compatibility with existing imports/tests.
4721
4875
  */
4722
- function hasToolResultContent(messages) {
4723
- return messages.some((msg) => Array.isArray(msg.content) && msg.content.some((block) => block.type === "tool_result"));
4724
- }
4876
+ const EFFORT_ORDER = [
4877
+ "low",
4878
+ "medium",
4879
+ "high",
4880
+ "xhigh"
4881
+ ];
4725
4882
  /**
4726
- * Inject web search results into the Anthropic system field.
4727
- * Handles three cases: absent, string, or array of content blocks.
4728
- * When array, prepends without cache_control to preserve existing directives.
4883
+ * Bucket a thinking budget into a Copilot reasoning-effort string.
4884
+ * `<2000`→low, `<8000`→medium, `<24000`→high, else→xhigh.
4885
+ * Defaults missing/non-numeric budgets to 8000 ("high").
4729
4886
  */
4730
- function injectSearchResults(body, searchContext) {
4731
- if (body.system === void 0 || body.system === null) body.system = searchContext;
4732
- else if (typeof body.system === "string") body.system = `${searchContext}\n\n${body.system}`;
4733
- else if (Array.isArray(body.system)) body.system = [{
4734
- type: "text",
4735
- text: searchContext
4736
- }, ...body.system];
4887
+ function bucketEffort(budget) {
4888
+ const n = typeof budget === "number" && Number.isFinite(budget) ? budget : 8e3;
4889
+ if (n < 2e3) return "low";
4890
+ if (n < 8e3) return "medium";
4891
+ if (n < 24e3) return "high";
4892
+ return "xhigh";
4737
4893
  }
4738
4894
  /**
4739
- * Strip web_search tools from the request and clean up tool_choice.
4740
- * Returns the modified body object.
4895
+ * Clamp a bucketed effort to the closest value in `supported`. Ties resolve to
4896
+ * the lower-tier option (per EFFORT_ORDER).
4897
+ *
4898
+ * Iterates EFFORT_ORDER (canonical low→xhigh) so the first match on a given
4899
+ * distance is always the lower-tier value, regardless of input order in
4900
+ * `supported`.
4741
4901
  */
4742
- function stripWebSearchTool(body) {
4743
- if (!body.tools) return;
4744
- body.tools = body.tools.filter((tool) => !isWebSearchTool$1(tool));
4745
- if (body.tools.length === 0) {
4746
- body.tools = void 0;
4747
- body.tool_choice = void 0;
4748
- } else if (body.tool_choice && typeof body.tool_choice === "object" && body.tool_choice.type === "tool") {
4749
- const choiceName = body.tool_choice.name;
4750
- if (choiceName && !body.tools.some((tool) => tool.name === choiceName)) body.tool_choice = { type: "auto" };
4902
+ function clampEffort(bucketed, supported) {
4903
+ if (supported.includes(bucketed)) return bucketed;
4904
+ const targetIdx = EFFORT_ORDER.indexOf(bucketed);
4905
+ let best;
4906
+ let bestDist = Infinity;
4907
+ for (let i = 0; i < EFFORT_ORDER.length; i++) {
4908
+ const value = EFFORT_ORDER[i];
4909
+ if (!supported.includes(value)) continue;
4910
+ const dist = Math.abs(i - targetIdx);
4911
+ if (dist < bestDist) {
4912
+ bestDist = dist;
4913
+ best = value;
4914
+ }
4915
+ }
4916
+ return best ?? bucketed;
4917
+ }
4918
+
4919
+ //#endregion
4920
+ //#region src/lib/anthropic-translate/anthropic-request.ts
4921
+ /** Flatten Anthropic `system` (string | array of text blocks) into a string. */
4922
+ function flattenSystem(system) {
4923
+ if (typeof system === "string") return system.length > 0 ? system : void 0;
4924
+ if (Array.isArray(system)) {
4925
+ let s = "";
4926
+ for (const block of system) if (block && typeof block === "object" && block.type === "text") {
4927
+ const t = block.text;
4928
+ if (typeof t === "string") s += t;
4929
+ }
4930
+ return s.length > 0 ? s : void 0;
4931
+ }
4932
+ }
4933
+ /**
4934
+ * Parse an Anthropic `tool_result.content` (string | block array) into the
4935
+ * plain-text `output` for the Responses `function_call_output` (a string-only
4936
+ * item) PLUS any image parts found in the content. A `function_call_output`
4937
+ * cannot carry images, so the caller emits the extracted images as a follow-up
4938
+ * user message (Claude Code browser screenshots/observations arrive this way).
4939
+ * `isError` (the tool_result `is_error` flag) is preserved by prefixing the
4940
+ * text so the model still learns the tool call failed.
4941
+ */
4942
+ function parseToolResultContent(content, isError) {
4943
+ const images = [];
4944
+ let text = "";
4945
+ if (typeof content === "string") text = content;
4946
+ else if (Array.isArray(content)) for (const block of content) {
4947
+ if (!block || typeof block !== "object") continue;
4948
+ const b = block;
4949
+ if (b.type === "text" && typeof b.text === "string") text += b.text;
4950
+ else if (b.type === "image") {
4951
+ const img = anthropicImageToNeutral(b.source);
4952
+ if (img) images.push(img);
4953
+ }
4954
+ }
4955
+ if (images.length > 0 && text.length === 0) text = "[image result below]";
4956
+ if (isError) text = text.length > 0 ? `[tool error] ${text}` : "[tool error]";
4957
+ return {
4958
+ output: text,
4959
+ images
4960
+ };
4961
+ }
4962
+ /** Map an Anthropic `image` block source to a neutral image part. */
4963
+ function anthropicImageToNeutral(source) {
4964
+ if (!source || typeof source !== "object") return null;
4965
+ if (source.type === "url" && typeof source.url === "string") return {
4966
+ type: "image",
4967
+ url: source.url
4968
+ };
4969
+ if (source.type === "base64" && typeof source.data === "string") return {
4970
+ type: "image",
4971
+ mimeType: typeof source.media_type === "string" ? source.media_type : "image/png",
4972
+ data: source.data
4973
+ };
4974
+ return null;
4975
+ }
4976
+ /** Concatenate the text of an Anthropic `document` `content`-source block array. */
4977
+ function joinDocumentContentText(content) {
4978
+ if (!Array.isArray(content)) return "";
4979
+ let text = "";
4980
+ for (const block of content) if (block && typeof block === "object") {
4981
+ const b = block;
4982
+ if (b.type === "text" && typeof b.text === "string") text += b.text;
4983
+ }
4984
+ return text;
4985
+ }
4986
+ /**
4987
+ * Map an Anthropic `document` block to a neutral content part.
4988
+ * - base64 source → neutral `document` (mimeType + data) → Responses
4989
+ * `input_file` with `file_data`; on the chat path → an inline text note
4990
+ * (Copilot's `/chat/completions` rejects file parts).
4991
+ * - url source → neutral `document` (url) → Responses `input_file.file_url`.
4992
+ * - text source (a plain-text document) → the doc's text folded into a `text`
4993
+ * part, so the model sees it on BOTH paths.
4994
+ * - content source (content-block document) → its text blocks folded into a
4995
+ * `text` part.
4996
+ * Missing/invalid fields (unknown source type, `file`-id references Copilot has
4997
+ * no Files API for, empty text) yield null and are dropped.
4998
+ */
4999
+ function anthropicDocumentToNeutral(b) {
5000
+ const source = b.source;
5001
+ if (!source || typeof source !== "object") return null;
5002
+ const s = source;
5003
+ const filename = typeof b.title === "string" && b.title.length > 0 ? b.title : "document.pdf";
5004
+ if (s.type === "base64" && typeof s.data === "string") return {
5005
+ type: "document",
5006
+ mimeType: typeof s.media_type === "string" ? s.media_type : "application/pdf",
5007
+ data: s.data,
5008
+ filename
5009
+ };
5010
+ if (s.type === "url" && typeof s.url === "string") return {
5011
+ type: "document",
5012
+ url: s.url,
5013
+ filename
5014
+ };
5015
+ if (s.type === "text" && typeof s.data === "string") return s.data.length > 0 ? {
5016
+ type: "text",
5017
+ text: s.data
5018
+ } : null;
5019
+ if (s.type === "content") {
5020
+ const text = joinDocumentContentText(s.content);
5021
+ return text.length > 0 ? {
5022
+ type: "text",
5023
+ text
5024
+ } : null;
4751
5025
  }
5026
+ return null;
4752
5027
  }
4753
5028
  /**
4754
- * Process web search if the request contains a web_search tool.
4755
- * Performs the search, injects results into system, and strips the tool.
4756
- * Returns the (possibly modified) body string to forward.
4757
- */
4758
- async function processWebSearch(rawBody) {
4759
- if (!rawBody.includes("web_search")) return rawBody;
4760
- let body;
4761
- try {
4762
- body = JSON.parse(rawBody);
4763
- } catch {
4764
- return rawBody;
5029
+ * Convert one Anthropic message into zero-or-more neutral messages. A user
5030
+ * message with `tool_result` blocks fans out: text/image content becomes a
5031
+ * user message and each tool_result becomes its own `toolResult` message,
5032
+ * emitted in wire order so a function_call_output never precedes its text.
5033
+ */
5034
+ function anthropicMessageToNeutral(msg) {
5035
+ const role = msg.role;
5036
+ const content = msg.content;
5037
+ if (role === "assistant") {
5038
+ const parts = [];
5039
+ if (typeof content === "string") {
5040
+ if (content.length > 0) parts.push({
5041
+ type: "text",
5042
+ text: content
5043
+ });
5044
+ } else if (Array.isArray(content)) for (const block of content) {
5045
+ if (!block || typeof block !== "object") continue;
5046
+ const b = block;
5047
+ if (b.type === "text" && typeof b.text === "string") parts.push({
5048
+ type: "text",
5049
+ text: b.text
5050
+ });
5051
+ else if (b.type === "tool_use") parts.push({
5052
+ type: "toolCall",
5053
+ id: typeof b.id === "string" ? b.id : "",
5054
+ name: typeof b.name === "string" ? b.name : "",
5055
+ arguments: b.input ?? {}
5056
+ });
5057
+ }
5058
+ return [{
5059
+ role: "assistant",
5060
+ content: parts
5061
+ }];
4765
5062
  }
4766
- if (!body.tools?.some((tool) => isWebSearchTool$1(tool))) return rawBody;
4767
- const query = hasToolResultContent(body.messages ?? []) ? void 0 : extractUserQuery$1(body.messages ?? []);
4768
- if (query) try {
4769
- const results = await searchWeb(query);
4770
- const searchContext = [
4771
- "[Web Search Results]",
4772
- results.content,
4773
- "",
4774
- results.references.map((r) => `- [${r.title}](${r.url})`).join("\n"),
4775
- "[End Web Search Results]"
4776
- ].join("\n");
4777
- injectSearchResults(body, searchContext);
4778
- } catch (error) {
4779
- consola.warn("Web search failed, continuing without results:", error);
5063
+ const out = [];
5064
+ let userParts = [];
5065
+ const flushUser = () => {
5066
+ if (userParts.length === 0) return;
5067
+ out.push({
5068
+ role: "user",
5069
+ content: userParts
5070
+ });
5071
+ userParts = [];
5072
+ };
5073
+ if (typeof content === "string") {
5074
+ if (content.length > 0) out.push({
5075
+ role: "user",
5076
+ content
5077
+ });
5078
+ return out;
5079
+ }
5080
+ if (Array.isArray(content)) for (const block of content) {
5081
+ if (!block || typeof block !== "object") continue;
5082
+ const b = block;
5083
+ if (b.type === "text" && typeof b.text === "string") userParts.push({
5084
+ type: "text",
5085
+ text: b.text
5086
+ });
5087
+ else if (b.type === "image") {
5088
+ const img = anthropicImageToNeutral(b.source);
5089
+ if (img) userParts.push(img);
5090
+ } else if (b.type === "document") {
5091
+ const doc = anthropicDocumentToNeutral(b);
5092
+ if (doc) userParts.push(doc);
5093
+ } else if (b.type === "tool_result") {
5094
+ flushUser();
5095
+ const { output, images } = parseToolResultContent(b.content, b.is_error === true);
5096
+ out.push({
5097
+ role: "toolResult",
5098
+ toolCallId: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
5099
+ output
5100
+ });
5101
+ if (images.length > 0) out.push({
5102
+ role: "user",
5103
+ content: images
5104
+ });
5105
+ }
4780
5106
  }
4781
- stripWebSearchTool(body);
4782
- return JSON.stringify(body);
5107
+ flushUser();
5108
+ return out;
4783
5109
  }
4784
- async function handleCompletion(c) {
4785
- const startTime = Date.now();
4786
- await checkRateLimit(state);
4787
- const rawBody = await c.req.text();
4788
- const debugEnabled = consola.level >= 4;
4789
- if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
4790
- if (process.env.GH_ROUTER_LOG_FIELDS === "1") {
4791
- let parsedForLog = void 0;
4792
- try {
4793
- parsedForLog = JSON.parse(rawBody);
4794
- } catch {}
4795
- logRequestFields({
4796
- path: c.req.path,
4797
- body: parsedForLog,
4798
- betaHeader: c.req.header("anthropic-beta")
5110
+ function parseTools(tools) {
5111
+ if (!Array.isArray(tools) || tools.length === 0) return void 0;
5112
+ const out = [];
5113
+ for (const tool of tools) {
5114
+ if (!tool || typeof tool !== "object") continue;
5115
+ const t = tool;
5116
+ if (typeof t.name !== "string" || t.name.length === 0) continue;
5117
+ const schema = t.input_schema ?? t.parameters;
5118
+ out.push({
5119
+ name: t.name,
5120
+ description: typeof t.description === "string" ? t.description : void 0,
5121
+ parameters: schema && typeof schema === "object" ? schema : {
5122
+ type: "object",
5123
+ properties: {}
5124
+ }
4799
5125
  });
4800
5126
  }
4801
- if (state.manualApprove) await awaitApproval();
4802
- const betaHeaders = extractBetaHeaders(c);
4803
- const advisorEnabled = isAdvisorRequested(c.req.header("anthropic-beta"));
4804
- let finalBody = await processWebSearch(rawBody);
4805
- finalBody = sanitizeAnthropicBody(finalBody);
4806
- if (advisorEnabled) {
4807
- finalBody = injectAdvisorTool(finalBody);
4808
- consola.info("ADVISOR enabled for this request — injecting __anthropic_advisor tool; will translate tool_use → server_tool_use{advisor} on the SSE stream");
5127
+ return out.length > 0 ? out : void 0;
5128
+ }
5129
+ function parseToolChoice(toolChoice) {
5130
+ if (!toolChoice || typeof toolChoice !== "object") return void 0;
5131
+ const tc = toolChoice;
5132
+ switch (tc.type) {
5133
+ case "auto": return "auto";
5134
+ case "any": return "required";
5135
+ case "none": return "none";
5136
+ case "tool": return typeof tc.name === "string" && tc.name.length > 0 ? {
5137
+ type: "function",
5138
+ name: tc.name
5139
+ } : void 0;
5140
+ default: return;
4809
5141
  }
4810
- if (finalBody.includes("\"mcp_servers\"")) try {
4811
- const probe = JSON.parse(finalBody);
4812
- if (Array.isArray(probe.mcp_servers) && probe.mcp_servers.length > 0) return c.json({
4813
- type: "error",
4814
- error: {
4815
- type: "invalid_request_error",
4816
- 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)"
4817
- }
4818
- }, 400);
4819
- } catch {}
5142
+ }
5143
+ /**
5144
+ * Anthropic carries `disable_parallel_tool_use` on the `tool_choice` object.
5145
+ * Returns `false` (the wire signal to disable parallel tool calls) only when it
5146
+ * is explicitly `true`; `undefined` otherwise, so the payload builders omit the
5147
+ * field rather than ever sending `parallel_tool_calls: true`.
5148
+ */
5149
+ function parseDisableParallelToolUse(toolChoice) {
5150
+ if (!toolChoice || typeof toolChoice !== "object") return void 0;
5151
+ return toolChoice.disable_parallel_tool_use === true ? false : void 0;
5152
+ }
5153
+ /** Default absent Anthropic `thinking` to high effort, clamped by the model.
5154
+ * Returns undefined for a model that advertises NO `reasoning_effort` allowlist
5155
+ * — such a model may not support reasoning at all, so forcing an effort could
5156
+ * 400; leaving it unset preserves the pre-default safe behavior for that case. */
5157
+ function defaultReasoningEffort(model) {
5158
+ const supported = model?.capabilities?.supports?.reasoning_effort;
5159
+ return Array.isArray(supported) && supported.length > 0 ? clampEffort("high", supported) : void 0;
5160
+ }
5161
+ /**
5162
+ * Map Anthropic `thinking` to a Responses reasoning effort, clamped to the
5163
+ * model's `reasoning_effort` allowlist. Returns undefined when thinking is
5164
+ * absent/disabled/non-enabled; the absent default is applied at the call site.
5165
+ */
5166
+ function parseReasoningEffort(thinking, model) {
5167
+ if (!thinking || typeof thinking !== "object") return void 0;
5168
+ const t = thinking;
5169
+ if (t.type !== "enabled") return void 0;
5170
+ const bucketed = bucketEffort(t.budget_tokens);
5171
+ const supported = model?.capabilities?.supports?.reasoning_effort;
5172
+ return Array.isArray(supported) && supported.length > 0 ? clampEffort(bucketed, supported) : bucketed;
5173
+ }
5174
+ /**
5175
+ * Parse an already-JSON-parsed Anthropic Messages body into the neutral shape.
5176
+ * `resolvedModel` is the catalog id the request will run on; `model` its
5177
+ * catalog entry (for the reasoning-effort allowlist).
5178
+ */
5179
+ function parseAnthropicRequest(body, resolvedModel, model) {
5180
+ const messages = [];
5181
+ if (Array.isArray(body.messages)) {
5182
+ for (const msg of body.messages) if (msg && typeof msg === "object") for (const n of anthropicMessageToNeutral(msg)) messages.push(n);
5183
+ }
5184
+ const maxTokens = typeof body.max_tokens === "number" && body.max_tokens > 0 ? body.max_tokens : void 0;
5185
+ const stopSequences = Array.isArray(body.stop_sequences) ? body.stop_sequences.filter((s) => typeof s === "string") : void 0;
5186
+ return {
5187
+ model: resolvedModel,
5188
+ instructions: flattenSystem(body.system),
5189
+ messages,
5190
+ tools: parseTools(body.tools),
5191
+ toolChoice: parseToolChoice(body.tool_choice),
5192
+ parallelToolCalls: parseDisableParallelToolUse(body.tool_choice),
5193
+ reasoningEffort: body.thinking === void 0 ? defaultReasoningEffort(model) : parseReasoningEffort(body.thinking, model),
5194
+ maxOutputTokens: maxTokens,
5195
+ stopSequences: stopSequences && stopSequences.length > 0 ? stopSequences : void 0,
5196
+ stream: body.stream === true
5197
+ };
5198
+ }
5199
+ /** Build the Copilot `/responses` payload from a parsed Anthropic request. */
5200
+ function parsedToResponsesPayload(parsed) {
5201
+ return assembleResponsesPayload({
5202
+ model: parsed.model,
5203
+ instructions: parsed.instructions,
5204
+ messages: parsed.messages,
5205
+ tools: parsed.tools,
5206
+ toolChoice: parsed.toolChoice,
5207
+ reasoningEffort: parsed.reasoningEffort,
5208
+ maxOutputTokens: parsed.maxOutputTokens,
5209
+ stopSequences: parsed.stopSequences,
5210
+ parallelToolCalls: parsed.parallelToolCalls,
5211
+ stream: parsed.stream
5212
+ });
5213
+ }
5214
+
5215
+ //#endregion
5216
+ //#region src/lib/anthropic-translate/chat-request.ts
5217
+ /** Build the `data:` URI (base64) or return the verbatim URL for an image part. */
5218
+ function imageUrlFor(part) {
5219
+ if (typeof part.url === "string" && part.url.length > 0) return part.url;
5220
+ return `data:${part.mimeType ?? "image/png"};base64,${part.data ?? ""}`;
5221
+ }
5222
+ /**
5223
+ * A brief inline note standing in for a document on the chat path. Copilot's
5224
+ * `/chat/completions` rejects file content parts (`type` must be `image_url` or
5225
+ * `text` → HTTP 400), so a document (PDF) can't be forwarded to a Gemini model;
5226
+ * the note keeps the document from being silently dropped and tells the model
5227
+ * one was provided but is unavailable, instead of 400ing the request.
5228
+ *
5229
+ * The note is wrapped in leading + trailing newlines so it is always DELIMITED
5230
+ * from adjacent user text — in the string-collapse branch it can't glue onto a
5231
+ * neighboring text run (`...[model]what is this?`), and in the content-parts
5232
+ * branch it stands as its own line. Regular text-to-text concatenation is left
5233
+ * untouched (only the note carries the delimiter), so wire order is preserved.
5234
+ */
5235
+ function documentNote(part) {
5236
+ return `\n[document "${part.filename ?? "document"}" attached but not supported for this model]\n`;
5237
+ }
5238
+ /**
5239
+ * A user turn: plain string when there are no images; otherwise OpenAI content
5240
+ * parts (`text` / `image_url`). Mirrors `neutralUserToResponses` so the two
5241
+ * shims encode a multimodal user turn the same way.
5242
+ */
5243
+ function neutralUserToChat(m) {
5244
+ if (typeof m.content === "string") return {
5245
+ role: "user",
5246
+ content: m.content
5247
+ };
5248
+ if (!m.content.some((c) => c.type === "image")) {
5249
+ let text = "";
5250
+ for (const c of m.content) if (c.type === "text") text += c.text;
5251
+ else if (c.type === "document") text += documentNote(c);
5252
+ return {
5253
+ role: "user",
5254
+ content: text
5255
+ };
5256
+ }
5257
+ const parts = [];
5258
+ for (const c of m.content) if (c.type === "text") parts.push({
5259
+ type: "text",
5260
+ text: c.text
5261
+ });
5262
+ else if (c.type === "image") parts.push({
5263
+ type: "image_url",
5264
+ image_url: { url: imageUrlFor(c) }
5265
+ });
5266
+ else if (c.type === "document") parts.push({
5267
+ type: "text",
5268
+ text: documentNote(c)
5269
+ });
5270
+ return {
5271
+ role: "user",
5272
+ content: parts
5273
+ };
5274
+ }
5275
+ /**
5276
+ * An assistant turn: text parts collapse into `content`, tool_use parts become
5277
+ * OpenAI `tool_calls`. Unlike the Responses path (which flushes text before each
5278
+ * call to preserve interleaving), the chat wire shape carries all text on
5279
+ * `content` and all calls on `tool_calls`, so ordering within the turn is not
5280
+ * representable — matching how OpenAI itself echoes an assistant turn. When the
5281
+ * turn is tool-calls-only, `content` is `null` (OpenAI convention).
5282
+ */
5283
+ function neutralAssistantToChat(m) {
5284
+ let text = "";
5285
+ const toolCalls = [];
5286
+ for (const c of m.content) if (c.type === "text") text += c.text;
5287
+ else if (c.type === "toolCall") toolCalls.push({
5288
+ id: c.id,
5289
+ type: "function",
5290
+ function: {
5291
+ name: c.name,
5292
+ arguments: JSON.stringify(c.arguments ?? {})
5293
+ }
5294
+ });
5295
+ const msg = {
5296
+ role: "assistant",
5297
+ content: text.length > 0 ? text : toolCalls.length > 0 ? null : ""
5298
+ };
5299
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
5300
+ return msg;
5301
+ }
5302
+ /** Translate one neutral message into a single chat/completions message. */
5303
+ function neutralMessageToChat(m) {
5304
+ if (m.role === "user") return neutralUserToChat(m);
5305
+ if (m.role === "assistant") return neutralAssistantToChat(m);
5306
+ return {
5307
+ role: "tool",
5308
+ tool_call_id: m.toolCallId,
5309
+ content: m.output
5310
+ };
5311
+ }
5312
+ function neutralToolsToChat(tools) {
5313
+ if (!tools || tools.length === 0) return void 0;
5314
+ return tools.map((t) => ({
5315
+ type: "function",
5316
+ function: {
5317
+ name: t.name,
5318
+ description: t.description,
5319
+ parameters: t.parameters ?? {
5320
+ type: "object",
5321
+ properties: {}
5322
+ }
5323
+ }
5324
+ }));
5325
+ }
5326
+ /**
5327
+ * Convert the parsed (Responses-flat) `tool_choice` to the chat NESTED form.
5328
+ * A forced tool is `{type:"function", function:{name}}` on chat/completions —
5329
+ * distinct from the Responses flat `{type:"function", name}`. `"auto"` /
5330
+ * `"required"` / `"none"` pass through unchanged.
5331
+ */
5332
+ function toolChoiceToChat(tc) {
5333
+ if (tc === void 0) return void 0;
5334
+ if (typeof tc === "string") return tc === "auto" || tc === "none" || tc === "required" ? tc : void 0;
5335
+ if (tc.type === "function" && typeof tc.name === "string" && tc.name.length > 0) return {
5336
+ type: "function",
5337
+ function: { name: tc.name }
5338
+ };
5339
+ }
5340
+ /** Assemble the `/chat/completions` payload from a parsed Anthropic request. */
5341
+ function parsedToChatPayload(parsed) {
5342
+ const messages = [];
5343
+ if (parsed.instructions) messages.push({
5344
+ role: "system",
5345
+ content: parsed.instructions
5346
+ });
5347
+ for (const m of parsed.messages) messages.push(neutralMessageToChat(m));
5348
+ const payload = {
5349
+ model: parsed.model,
5350
+ messages,
5351
+ stream: parsed.stream
5352
+ };
5353
+ const tools = neutralToolsToChat(parsed.tools);
5354
+ if (tools && tools.length > 0) {
5355
+ payload.tools = tools;
5356
+ payload.tool_choice = toolChoiceToChat(parsed.toolChoice) ?? "auto";
5357
+ }
5358
+ if (parsed.reasoningEffort && parsed.reasoningEffort !== "off") payload.reasoning_effort = parsed.reasoningEffort;
5359
+ if (typeof parsed.maxOutputTokens === "number" && parsed.maxOutputTokens > 0) payload.max_tokens = parsed.maxOutputTokens;
5360
+ if (parsed.stopSequences && parsed.stopSequences.length > 0) payload.stop = parsed.stopSequences;
5361
+ if (parsed.parallelToolCalls === false) payload.parallel_tool_calls = false;
5362
+ return payload;
5363
+ }
5364
+
5365
+ //#endregion
5366
+ //#region src/lib/anthropic-translate/chat-egress.ts
5367
+ /** Synthesize a matchable Anthropic tool_use id when the upstream id is absent. */
5368
+ function makeToolUseId$1() {
5369
+ return `toolu_${randomUUID().replace(/-/g, "")}`;
5370
+ }
5371
+ function parseToolArgs$1(raw) {
5372
+ if (typeof raw !== "string" || raw.trim().length === 0) return {};
5373
+ try {
5374
+ const parsed = JSON.parse(raw);
5375
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
5376
+ } catch {}
5377
+ return {};
5378
+ }
5379
+ function anthropicUsageFromChat(u) {
5380
+ if (!u) return {};
5381
+ return {
5382
+ input_tokens: u.prompt_tokens ?? 0,
5383
+ output_tokens: u.completion_tokens ?? 0,
5384
+ cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens ?? 0,
5385
+ cache_creation_input_tokens: 0
5386
+ };
5387
+ }
5388
+ /**
5389
+ * Map a chat/completions `finish_reason` to an Anthropic stop_reason. A
5390
+ * truncated (`length`) response is `max_tokens` even when a partial tool call
5391
+ * is present — the response was cut — mirroring the Responses egress precedence.
5392
+ * `tool_calls` (or any buffered tool) → `tool_use`; everything else (`stop`,
5393
+ * `content_filter`, null) → `end_turn`.
5394
+ */
5395
+ function chatStopReason(finishReason, sawTool) {
5396
+ if (finishReason === "length") return "max_tokens";
5397
+ if (finishReason === "tool_calls" || sawTool) return "tool_use";
5398
+ return "end_turn";
5399
+ }
5400
+ /**
5401
+ * Map a non-streaming chat/completions object to an Anthropic Messages object.
5402
+ * The first choice's `message.content` becomes a text block (when non-empty)
5403
+ * and each `message.tool_calls[]` becomes a tool_use block.
5404
+ */
5405
+ function chatResponseToAnthropicMessage(resp, modelId) {
5406
+ const choice = resp.choices?.[0];
5407
+ const content = [];
5408
+ let sawTool = false;
5409
+ const message = choice?.message;
5410
+ if (message) {
5411
+ if (typeof message.content === "string" && message.content.length > 0) content.push({
5412
+ type: "text",
5413
+ text: message.content
5414
+ });
5415
+ if (Array.isArray(message.tool_calls)) for (const tc of message.tool_calls) {
5416
+ sawTool = true;
5417
+ const rawId = typeof tc.id === "string" ? tc.id : "";
5418
+ content.push({
5419
+ type: "tool_use",
5420
+ id: rawId.length > 0 ? rawId : makeToolUseId$1(),
5421
+ name: typeof tc.function?.name === "string" ? tc.function.name : "",
5422
+ input: parseToolArgs$1(tc.function?.arguments)
5423
+ });
5424
+ }
5425
+ }
5426
+ const usage = anthropicUsageFromChat(resp.usage);
5427
+ const stopReason = chatStopReason(choice?.finish_reason ?? null, sawTool);
5428
+ return {
5429
+ id: typeof resp.id === "string" && resp.id.length > 0 ? `msg_${resp.id}` : makeMessageId(),
5430
+ type: "message",
5431
+ role: "assistant",
5432
+ model: modelId,
5433
+ content,
5434
+ stop_reason: stopReason,
5435
+ stop_sequence: null,
5436
+ usage: {
5437
+ input_tokens: usage.input_tokens ?? 0,
5438
+ output_tokens: usage.output_tokens ?? 0,
5439
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
5440
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
5441
+ }
5442
+ };
5443
+ }
5444
+ /**
5445
+ * Streaming synthesizer: consume a chat/completions SSE iterable, yield the
5446
+ * Anthropic event sequence. Emits `message_start` first, streams text live,
5447
+ * buffers tool calls per OpenAI array index and flushes them atomically at
5448
+ * end-of-stream (in numeric index order), then a terminal `message_delta`
5449
+ * (accumulated usage + stop_reason) and `message_stop`. The `[DONE]` sentinel
5450
+ * is the authoritative clean-end marker: a stream that ends WITHOUT it is
5451
+ * treated as truncated and throws so the stream adapter can emit a terminal
5452
+ * `event: error`. A clean `[DONE]` carrying no chunk-level `finish_reason`
5453
+ * still completes cleanly (stop_reason from any buffered tool, else `end_turn`).
5454
+ */
5455
+ async function* synthAnthropicFromChat(upstream, opts) {
5456
+ const messageId = opts.messageId ?? makeMessageId();
5457
+ let nextIndex = 0;
5458
+ let activeTextIndex = null;
5459
+ const toolByIndex = /* @__PURE__ */ new Map();
5460
+ let usageIn = 0;
5461
+ let usageOut = 0;
5462
+ let usageCacheRead = 0;
5463
+ let finishReason = null;
5464
+ let sawDone = false;
5465
+ yield makeMessageStart(messageId, opts.modelId);
5466
+ for await (const evt of upstream) {
5467
+ const data = evt?.data;
5468
+ if (data == null) continue;
5469
+ if (data === "[DONE]") {
5470
+ sawDone = true;
5471
+ break;
5472
+ }
5473
+ let chunk;
5474
+ try {
5475
+ chunk = JSON.parse(data);
5476
+ } catch {
5477
+ continue;
5478
+ }
5479
+ if (chunk.usage) {
5480
+ usageIn = Math.max(usageIn, chunk.usage.prompt_tokens ?? 0);
5481
+ usageOut = Math.max(usageOut, chunk.usage.completion_tokens ?? 0);
5482
+ usageCacheRead = Math.max(usageCacheRead, chunk.usage.prompt_tokens_details?.cached_tokens ?? 0);
5483
+ }
5484
+ const choice = chunk.choices?.[0];
5485
+ if (!choice) continue;
5486
+ const delta = choice.delta;
5487
+ if (delta && typeof delta.content === "string" && delta.content.length > 0) {
5488
+ if (activeTextIndex == null) {
5489
+ activeTextIndex = nextIndex++;
5490
+ yield makeContentBlockStart(activeTextIndex, {
5491
+ type: "text",
5492
+ text: ""
5493
+ });
5494
+ }
5495
+ yield makeTextDelta(activeTextIndex, delta.content);
5496
+ }
5497
+ if (delta && Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) {
5498
+ if (activeTextIndex != null) {
5499
+ yield makeContentBlockStop(activeTextIndex);
5500
+ activeTextIndex = null;
5501
+ }
5502
+ for (const tcd of delta.tool_calls) {
5503
+ if (tcd == null || typeof tcd.index !== "number") continue;
5504
+ let entry = toolByIndex.get(tcd.index);
5505
+ if (!entry) {
5506
+ entry = {
5507
+ id: "",
5508
+ name: "",
5509
+ args: ""
5510
+ };
5511
+ toolByIndex.set(tcd.index, entry);
5512
+ }
5513
+ if (typeof tcd.id === "string" && tcd.id.length > 0) entry.id = tcd.id;
5514
+ const name$1 = tcd.function?.name;
5515
+ if (typeof name$1 === "string" && name$1.length > 0) entry.name = name$1;
5516
+ const argDelta = tcd.function?.arguments;
5517
+ if (typeof argDelta === "string" && argDelta.length > 0) entry.args += argDelta;
5518
+ }
5519
+ }
5520
+ if (choice.finish_reason != null) finishReason = choice.finish_reason;
5521
+ }
5522
+ if (!sawDone) throw new Error("chat stream ended without a [DONE] sentinel (truncated)");
5523
+ if (activeTextIndex != null) {
5524
+ yield makeContentBlockStop(activeTextIndex);
5525
+ activeTextIndex = null;
5526
+ }
5527
+ let sawTool = false;
5528
+ const orderedTools = [...toolByIndex.entries()].sort((a, b) => a[0] - b[0]);
5529
+ for (const [, entry] of orderedTools) {
5530
+ sawTool = true;
5531
+ const index = nextIndex++;
5532
+ yield makeContentBlockStart(index, {
5533
+ type: "tool_use",
5534
+ id: entry.id.length > 0 ? entry.id : makeToolUseId$1(),
5535
+ name: entry.name,
5536
+ input: {}
5537
+ });
5538
+ yield makeInputJsonDelta(index, JSON.stringify(parseToolArgs$1(entry.args)));
5539
+ yield makeContentBlockStop(index);
5540
+ }
5541
+ yield makeMessageDelta(chatStopReason(finishReason, sawTool), null, {
5542
+ input_tokens: usageIn,
5543
+ output_tokens: usageOut,
5544
+ cache_read_input_tokens: usageCacheRead,
5545
+ cache_creation_input_tokens: 0
5546
+ });
5547
+ yield makeMessageStop();
5548
+ }
5549
+
5550
+ //#endregion
5551
+ //#region src/lib/anthropic-translate/responses-egress.ts
5552
+ /**
5553
+ * Stable map key for a `/responses` output item: prefer `output_index`
5554
+ * (constant per item), fall back to the opaque id only when absent. Namespaced
5555
+ * so a numeric index and a string id can never collide.
5556
+ */
5557
+ function responsesKey(outputIndex, fallbackId) {
5558
+ if (typeof outputIndex === "number") return `oi:${outputIndex}`;
5559
+ if (typeof fallbackId === "string" && fallbackId.length > 0) return `id:${fallbackId}`;
5560
+ }
5561
+ /** Synthesize a matchable Anthropic tool_use id when the upstream id is absent. */
5562
+ function makeToolUseId() {
5563
+ return `toolu_${randomUUID().replace(/-/g, "")}`;
5564
+ }
5565
+ /** First non-empty string among the candidates, or "" when none qualifies. */
5566
+ function firstNonEmpty(...vals) {
5567
+ for (const v of vals) if (typeof v === "string" && v.length > 0) return v;
5568
+ return "";
5569
+ }
5570
+ function anthropicUsageFromResponses(u) {
5571
+ if (!u) return {};
5572
+ return {
5573
+ input_tokens: u.input_tokens ?? 0,
5574
+ output_tokens: u.output_tokens ?? 0,
5575
+ cache_read_input_tokens: u.input_tokens_details?.cached_tokens ?? 0,
5576
+ cache_creation_input_tokens: 0
5577
+ };
5578
+ }
5579
+ function parseToolArgs(raw) {
5580
+ if (typeof raw !== "string" || raw.trim().length === 0) return {};
5581
+ try {
5582
+ const parsed = JSON.parse(raw);
5583
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
5584
+ } catch {}
5585
+ return {};
5586
+ }
5587
+ /**
5588
+ * Map a non-streaming Responses object to an Anthropic Messages object.
5589
+ *
5590
+ * stop_reason precedence for a completed non-streaming response:
5591
+ * an incomplete/max-output response is `max_tokens` even if a partial tool call
5592
+ * is present (the response was truncated), else a function_call → `tool_use`,
5593
+ * else `end_turn`.
5594
+ */
5595
+ function responsesResponseToAnthropicMessage(resp, modelId) {
5596
+ const output = Array.isArray(resp.output) ? resp.output : [];
5597
+ const content = [];
5598
+ let sawToolUse = false;
5599
+ for (const rawItem of output) {
5600
+ if (!rawItem || typeof rawItem !== "object") continue;
5601
+ const item = rawItem;
5602
+ if (item.type === "message") {
5603
+ let text = "";
5604
+ if (Array.isArray(item.content)) {
5605
+ for (const part of item.content) if (part && typeof part === "object" && part.type === "output_text" && typeof part.text === "string") text += part.text;
5606
+ }
5607
+ if (text.length > 0) content.push({
5608
+ type: "text",
5609
+ text
5610
+ });
5611
+ } else if (item.type === "function_call") {
5612
+ sawToolUse = true;
5613
+ 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 : "";
5614
+ content.push({
5615
+ type: "tool_use",
5616
+ id: rawId.length > 0 ? rawId : makeToolUseId(),
5617
+ name: typeof item.name === "string" ? item.name : "",
5618
+ input: parseToolArgs(item.arguments)
5619
+ });
5620
+ } else if (item.type === "reasoning") {
5621
+ let thinking = "";
5622
+ if (Array.isArray(item.summary)) {
5623
+ for (const part of item.summary) if (part && typeof part === "object" && typeof part.text === "string") thinking += part.text;
5624
+ }
5625
+ if (thinking.length > 0) content.push({
5626
+ type: "thinking",
5627
+ thinking,
5628
+ signature: typeof item.encrypted_content === "string" ? item.encrypted_content : ""
5629
+ });
5630
+ }
5631
+ }
5632
+ const usage = anthropicUsageFromResponses(resp.usage);
5633
+ const stopReason = resp.status === "incomplete" && resp.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : sawToolUse ? "tool_use" : "end_turn";
5634
+ return {
5635
+ id: typeof resp.id === "string" && resp.id.length > 0 ? `msg_${resp.id}` : makeMessageId(),
5636
+ type: "message",
5637
+ role: "assistant",
5638
+ model: modelId,
5639
+ content,
5640
+ stop_reason: stopReason,
5641
+ stop_sequence: null,
5642
+ usage: {
5643
+ input_tokens: usage.input_tokens ?? 0,
5644
+ output_tokens: usage.output_tokens ?? 0,
5645
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
5646
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
5647
+ }
5648
+ };
5649
+ }
5650
+ /**
5651
+ * Streaming synthesizer: consume a `/responses` SSE iterable, yield the
5652
+ * Anthropic event sequence. Emits `message_start` first, then content blocks in
5653
+ * item order, then a terminal `message_delta` (accumulated usage + stop_reason)
5654
+ * and `message_stop`. A `response.failed` throws so the stream adapter can emit
5655
+ * a terminal `event: error`; a stream that ends WITHOUT a terminal event
5656
+ * (`completed`/`incomplete`/`failed`) is treated as truncated and throws too.
5657
+ */
5658
+ async function* synthAnthropicFromResponses(upstream, opts) {
5659
+ const messageId = opts.messageId ?? makeMessageId();
5660
+ const q = [];
5661
+ let nextIndex = 0;
5662
+ let current = null;
5663
+ const toolByKey = /* @__PURE__ */ new Map();
5664
+ const thinkingByKey = /* @__PURE__ */ new Map();
5665
+ const textByKey = /* @__PURE__ */ new Map();
5666
+ let usageIn = 0;
5667
+ let usageOut = 0;
5668
+ let usageCacheRead = 0;
5669
+ let sawTool = false;
5670
+ let hitMaxTokens = false;
5671
+ let sawTerminal = false;
5672
+ const closeCurrent = () => {
5673
+ if (!current) return;
5674
+ q.push(makeContentBlockStop(current.index));
5675
+ current = null;
5676
+ };
5677
+ const currentKind = () => current ? current.kind : null;
5678
+ const currentIndex = () => current ? current.index : null;
5679
+ const ensureTextState = (key) => {
5680
+ const existing = textByKey.get(key);
5681
+ if (existing != null && current?.kind === "text" && current.index === existing.index) return existing;
5682
+ closeCurrent();
5683
+ const index = nextIndex++;
5684
+ const state$1 = {
5685
+ index,
5686
+ emitted: ""
5687
+ };
5688
+ textByKey.set(key, state$1);
5689
+ current = {
5690
+ index,
5691
+ kind: "text"
5692
+ };
5693
+ q.push(makeContentBlockStart(index, {
5694
+ type: "text",
5695
+ text: ""
5696
+ }));
5697
+ return state$1;
5698
+ };
5699
+ const ensureThinking = (key) => {
5700
+ if (current?.kind === "thinking" && thinkingByKey.get(key) === current.index) return current.index;
5701
+ closeCurrent();
5702
+ const index = nextIndex++;
5703
+ current = {
5704
+ index,
5705
+ kind: "thinking"
5706
+ };
5707
+ thinkingByKey.set(key, index);
5708
+ q.push(makeContentBlockStart(index, {
5709
+ type: "thinking",
5710
+ thinking: ""
5711
+ }));
5712
+ return index;
5713
+ };
5714
+ const emitTool = (t) => {
5715
+ if (t.emitted) return;
5716
+ closeCurrent();
5717
+ const index = nextIndex++;
5718
+ q.push(makeContentBlockStart(index, {
5719
+ type: "tool_use",
5720
+ id: t.id,
5721
+ name: t.name,
5722
+ input: {}
5723
+ }));
5724
+ const args = t.argsBuffer.length > 0 ? t.argsBuffer : "{}";
5725
+ q.push(makeInputJsonDelta(index, args));
5726
+ q.push(makeContentBlockStop(index));
5727
+ t.emitted = true;
5728
+ sawTool = true;
5729
+ };
5730
+ q.push(makeMessageStart(messageId, opts.modelId));
5731
+ for (const e of q) yield e;
5732
+ q.length = 0;
5733
+ for await (const evt of upstream) {
5734
+ const data = evt?.data;
5735
+ if (data == null) continue;
5736
+ if (data === "[DONE]") break;
5737
+ let ev;
5738
+ try {
5739
+ ev = JSON.parse(data);
5740
+ } catch {
5741
+ continue;
5742
+ }
5743
+ switch (ev.type) {
5744
+ case "response.output_text.delta": {
5745
+ const d = ev.delta;
5746
+ if (typeof d !== "string" || d.length === 0) break;
5747
+ const state$1 = ensureTextState(responsesKey(ev.output_index, ev.item_id) ?? "text");
5748
+ state$1.emitted += d;
5749
+ q.push(makeTextDelta(state$1.index, d));
5750
+ break;
5751
+ }
5752
+ case "response.output_text.done": {
5753
+ const key = responsesKey(ev.output_index, ev.item_id) ?? "text";
5754
+ const fullText = typeof ev.text === "string" ? ev.text : "";
5755
+ const existing = textByKey.get(key);
5756
+ if (existing == null) {
5757
+ if (fullText.length > 0) {
5758
+ const state$1 = ensureTextState(key);
5759
+ state$1.emitted = fullText;
5760
+ q.push(makeTextDelta(state$1.index, fullText));
5761
+ closeCurrent();
5762
+ }
5763
+ } else if (currentKind() === "text" && currentIndex() === existing.index) {
5764
+ if (fullText.length > existing.emitted.length && fullText.startsWith(existing.emitted)) {
5765
+ q.push(makeTextDelta(existing.index, fullText.slice(existing.emitted.length)));
5766
+ existing.emitted = fullText;
5767
+ }
5768
+ closeCurrent();
5769
+ }
5770
+ break;
5771
+ }
5772
+ case "response.reasoning_summary_text.delta":
5773
+ case "response.reasoning_text.delta": {
5774
+ const d = ev.delta;
5775
+ if (typeof d !== "string" || d.length === 0) break;
5776
+ const key = responsesKey(ev.output_index, ev.item_id) ?? "reasoning";
5777
+ q.push(makeThinkingDelta(ensureThinking(key), d));
5778
+ break;
5779
+ }
5780
+ case "response.reasoning_summary_text.done":
5781
+ case "response.reasoning_text.done": break;
5782
+ case "response.output_item.added": {
5783
+ const item = ev.item;
5784
+ if (item?.type === "function_call") {
5785
+ const key = responsesKey(ev.output_index, item.id);
5786
+ if (key == null || toolByKey.has(key)) break;
5787
+ const toolId = firstNonEmpty(item.call_id, item.id);
5788
+ toolByKey.set(key, {
5789
+ id: toolId.length > 0 ? toolId : makeToolUseId(),
5790
+ name: item.name ?? "",
5791
+ argsBuffer: "",
5792
+ emitted: false
5793
+ });
5794
+ sawTool = true;
5795
+ }
5796
+ break;
5797
+ }
5798
+ case "response.function_call_arguments.delta": {
5799
+ const key = responsesKey(ev.output_index, ev.item_id);
5800
+ if (key == null) break;
5801
+ const t = toolByKey.get(key);
5802
+ if (!t || t.emitted) break;
5803
+ const d = ev.delta;
5804
+ if (typeof d !== "string" || d.length === 0) break;
5805
+ t.argsBuffer += d;
5806
+ break;
5807
+ }
5808
+ case "response.function_call_arguments.done": {
5809
+ const key = responsesKey(ev.output_index, ev.item_id);
5810
+ if (key == null) break;
5811
+ const t = toolByKey.get(key);
5812
+ if (!t || t.emitted) break;
5813
+ if (typeof ev.arguments === "string") t.argsBuffer = ev.arguments;
5814
+ break;
5815
+ }
5816
+ case "response.output_item.done": {
5817
+ const item = ev.item;
5818
+ if (item?.type === "function_call") {
5819
+ const key = responsesKey(ev.output_index, item.id);
5820
+ if (key == null) break;
5821
+ const t = toolByKey.get(key);
5822
+ if (!t || t.emitted) break;
5823
+ const doneId = firstNonEmpty(item.call_id, item.id);
5824
+ if (doneId.length > 0) t.id = doneId;
5825
+ if (typeof item.name === "string" && item.name.length > 0) t.name = item.name;
5826
+ if (typeof item.arguments === "string") t.argsBuffer = item.arguments;
5827
+ emitTool(t);
5828
+ } else if (item?.type === "reasoning") {
5829
+ const key = responsesKey(ev.output_index, item.id);
5830
+ const idx = key != null ? thinkingByKey.get(key) : void 0;
5831
+ if (idx != null && currentKind() === "thinking" && currentIndex() === idx) {
5832
+ if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) q.push(makeSignatureDelta(idx, item.encrypted_content));
5833
+ closeCurrent();
5834
+ }
5835
+ }
5836
+ break;
5837
+ }
5838
+ case "response.completed":
5839
+ case "response.incomplete": {
5840
+ sawTerminal = true;
5841
+ const u = ev.response?.usage;
5842
+ if (u) {
5843
+ usageIn = Math.max(usageIn, u.input_tokens ?? 0);
5844
+ usageOut = Math.max(usageOut, u.output_tokens ?? 0);
5845
+ usageCacheRead = Math.max(usageCacheRead, u.input_tokens_details?.cached_tokens ?? 0);
5846
+ }
5847
+ if (ev.type === "response.incomplete" && ev.response?.incomplete_details?.reason === "max_output_tokens") hitMaxTokens = true;
5848
+ break;
5849
+ }
5850
+ case "response.failed": throw new Error(ev.response?.error?.message ?? "response.failed");
5851
+ default: break;
5852
+ }
5853
+ for (const e of q) yield e;
5854
+ q.length = 0;
5855
+ }
5856
+ if (!sawTerminal) throw new Error("responses stream ended without a terminal event (truncated)");
5857
+ closeCurrent();
5858
+ for (const t of toolByKey.values()) if (!t.emitted) emitTool(t);
5859
+ const stopReason = hitMaxTokens ? "max_tokens" : sawTool ? "tool_use" : "end_turn";
5860
+ q.push(makeMessageDelta(stopReason, null, {
5861
+ input_tokens: usageIn,
5862
+ output_tokens: usageOut,
5863
+ cache_read_input_tokens: usageCacheRead,
5864
+ cache_creation_input_tokens: 0
5865
+ }));
5866
+ q.push(makeMessageStop());
5867
+ for (const e of q) yield e;
5868
+ q.length = 0;
5869
+ }
5870
+
5871
+ //#endregion
5872
+ //#region src/lib/anthropic-translate/classifier.ts
5873
+ /**
5874
+ * Match "claude"/"anthropic" as a delimiter-bounded segment anywhere in a model
5875
+ * id: at the start, or after a `/ _ . : -` path/version delimiter, and followed
5876
+ * by end-of-string, another such delimiter, or a digit. This catches catalog
5877
+ * aliases like `github/claude-3-7-sonnet` or `anthropic/…` whose vendor is
5878
+ * "github" and whose family is empty — where the token only surfaces mid-id —
5879
+ * while NOT firing on incidental substrings like `notclaude`. Deliberately
5880
+ * over-inclusive at the boundaries: we fail CLOSED toward Claude (→ passthrough)
5881
+ * so a real Claude model can never be diverted to the non-Claude shim.
5882
+ */
5883
+ const CLAUDE_ID_RE = /(^|[/_.:-])(claude|anthropic)(?=$|[/_.:-]|\d)/i;
5884
+ /**
5885
+ * True when the target is a Claude / Anthropic model. Matches on any of:
5886
+ * catalog vendor containing "anthropic", capability family containing "claude",
5887
+ * or a Claude/anthropic path-segment (see `CLAUDE_ID_RE`) in ANY id we hold —
5888
+ * the resolved id (`modelId`), the pre-resolution request id (`originalModelId`),
5889
+ * or the catalog entry's own id (`model.id`). Conservative by design: when in
5890
+ * doubt it returns true so a Claude request can never be diverted to the shim.
5891
+ */
5892
+ function isClaudeModel(modelId, model, originalModelId) {
5893
+ if (model) {
5894
+ if ((model.vendor?.toLowerCase() ?? "").includes("anthropic")) return true;
5895
+ if ((model.capabilities?.family?.toLowerCase() ?? "").includes("claude")) return true;
5896
+ }
5897
+ return [
5898
+ modelId,
5899
+ originalModelId,
5900
+ model?.id
5901
+ ].some((id) => typeof id === "string" && CLAUDE_ID_RE.test(id));
5902
+ }
5903
+ /**
5904
+ * Decide the route for a resolved model id + its catalog entry.
5905
+ *
5906
+ * - No model id, or a Claude model → "claude-passthrough" (existing behaviour).
5907
+ * - A non-Claude model whose catalog endpoint is `/responses` → "responses-shim".
5908
+ * - A non-Claude model whose catalog endpoint is `/chat/completions` (gemini and
5909
+ * any chat-default model) → "chat-shim".
5910
+ * - A non-Claude model absent from the catalog (so we can't confirm an endpoint)
5911
+ * → "claude-passthrough" (unchanged; we don't divert what we can't classify).
5912
+ *
5913
+ * `originalModelId` is the optional pre-resolution request id; when supplied it
5914
+ * is checked for Claude-likeness alongside the resolved id so an alias that
5915
+ * resolves to a non-Claude-looking id can't slip past.
5916
+ */
5917
+ function classifyMessagesRoute(modelId, model, originalModelId) {
5918
+ if (!modelId) return "claude-passthrough";
5919
+ if (isClaudeModel(modelId, model, originalModelId)) return "claude-passthrough";
5920
+ if (!model) return "claude-passthrough";
5921
+ const endpoint = pickEndpoint(model);
5922
+ if (endpoint === "responses") return "responses-shim";
5923
+ if (endpoint === "chat") return "chat-shim";
5924
+ return "claude-passthrough";
5925
+ }
5926
+
5927
+ //#endregion
5928
+ //#region src/lib/anthropic-translate/index.ts
5929
+ const STREAM_HEADERS = {
5930
+ "content-type": "text/event-stream",
5931
+ "cache-control": "no-cache",
5932
+ "transfer-encoding": "chunked",
5933
+ connection: "keep-alive"
5934
+ };
5935
+ function isAsyncIterable(x) {
5936
+ return x != null && typeof x[Symbol.asyncIterator] === "function";
5937
+ }
5938
+ /**
5939
+ * Handle a `/v1/messages` request targeting a non-Claude `/responses` model.
5940
+ * Returns a streaming or non-streaming Anthropic-format Response. Upstream
5941
+ * non-2xx / abort errors are thrown (as HTTPError) and handled by the route's
5942
+ * `forwardError`, exactly like the passthrough path.
5943
+ */
5944
+ async function handleNonClaudeResponses(c, opts) {
5945
+ const routePath = c.req.path;
5946
+ let body;
5947
+ try {
5948
+ body = JSON.parse(opts.rawBody);
5949
+ } catch {
5950
+ return c.json({
5951
+ type: "error",
5952
+ error: {
5953
+ type: "invalid_request_error",
5954
+ message: "Request body is not valid JSON"
5955
+ }
5956
+ }, 400);
5957
+ }
5958
+ const parsed = parseAnthropicRequest(body, opts.modelId, opts.model);
5959
+ const payload = parsedToResponsesPayload(parsed);
5960
+ if (consola.level >= 4) consola.debug(`Anthropic-translate → /responses model=${opts.modelId} stream=${parsed.stream} tools=${parsed.tools?.length ?? 0} effort=${parsed.reasoningEffort ?? "none"}`);
5961
+ if (parsed.stream) {
5962
+ const aborter = new AbortController();
5963
+ const result = await createResponses(payload, opts.model?.requestHeaders, aborter.signal, true);
5964
+ if (!isAsyncIterable(result)) throw new Error("Upstream /responses did not return an SSE stream (stream: true expected)");
5965
+ logRequest({
5966
+ method: "POST",
5967
+ path: routePath,
5968
+ model: opts.originalModel,
5969
+ resolvedModel: opts.modelId,
5970
+ status: 200,
5971
+ streaming: true
5972
+ }, opts.model, opts.startTime);
5973
+ const stream = anthropicSseStreamFromEvents(synthAnthropicFromResponses(result, { modelId: opts.modelId }), {
5974
+ routePath,
5975
+ onCancel: () => aborter.abort()
5976
+ });
5977
+ return new Response(stream, {
5978
+ status: 200,
5979
+ headers: STREAM_HEADERS
5980
+ });
5981
+ }
5982
+ const anthropic = responsesResponseToAnthropicMessage(await createResponses(payload, opts.model?.requestHeaders, void 0, true), opts.modelId);
5983
+ logRequest({
5984
+ method: "POST",
5985
+ path: routePath,
5986
+ model: opts.originalModel,
5987
+ resolvedModel: opts.modelId,
5988
+ inputTokens: anthropic.usage.input_tokens,
5989
+ outputTokens: anthropic.usage.output_tokens,
5990
+ status: 200
5991
+ }, opts.model, opts.startTime);
5992
+ return c.json(anthropic, 200);
5993
+ }
5994
+ /**
5995
+ * Handle a `/v1/messages` request targeting a non-Claude `/chat/completions`
5996
+ * model (gemini). Twin of `handleNonClaudeResponses` — same lifecycle, abort,
5997
+ * and logging contract, but assembles a chat/completions payload and translates
5998
+ * the chat response (object or SSE) back to the Anthropic wire shape.
5999
+ */
6000
+ async function handleNonClaudeChat(c, opts) {
6001
+ const routePath = c.req.path;
6002
+ let body;
6003
+ try {
6004
+ body = JSON.parse(opts.rawBody);
6005
+ } catch {
6006
+ return c.json({
6007
+ type: "error",
6008
+ error: {
6009
+ type: "invalid_request_error",
6010
+ message: "Request body is not valid JSON"
6011
+ }
6012
+ }, 400);
6013
+ }
6014
+ const parsed = parseAnthropicRequest(body, opts.modelId, opts.model);
6015
+ const payload = parsedToChatPayload(parsed);
6016
+ 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"}`);
6017
+ if (parsed.stream) {
6018
+ const aborter = new AbortController();
6019
+ const result = await createChatCompletions(payload, opts.model?.requestHeaders, aborter.signal, true);
6020
+ if (!isAsyncIterable(result)) throw new Error("Upstream /chat/completions did not return an SSE stream (stream: true expected)");
6021
+ logRequest({
6022
+ method: "POST",
6023
+ path: routePath,
6024
+ model: opts.originalModel,
6025
+ resolvedModel: opts.modelId,
6026
+ status: 200,
6027
+ streaming: true
6028
+ }, opts.model, opts.startTime);
6029
+ const stream = anthropicSseStreamFromEvents(synthAnthropicFromChat(result, { modelId: opts.modelId }), {
6030
+ routePath,
6031
+ onCancel: () => aborter.abort()
6032
+ });
6033
+ return new Response(stream, {
6034
+ status: 200,
6035
+ headers: STREAM_HEADERS
6036
+ });
6037
+ }
6038
+ const anthropic = chatResponseToAnthropicMessage(await createChatCompletions(payload, opts.model?.requestHeaders, void 0, true), opts.modelId);
6039
+ logRequest({
6040
+ method: "POST",
6041
+ path: routePath,
6042
+ model: opts.originalModel,
6043
+ resolvedModel: opts.modelId,
6044
+ inputTokens: anthropic.usage.input_tokens,
6045
+ outputTokens: anthropic.usage.output_tokens,
6046
+ status: 200
6047
+ }, opts.model, opts.startTime);
6048
+ return c.json(anthropic, 200);
6049
+ }
6050
+
6051
+ //#endregion
6052
+ //#region src/routes/messages/handler.ts
6053
+ const isWebSearchTool$1 = (tool) => !!tool && typeof tool === "object" && (typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search");
6054
+ /**
6055
+ * Extract whitelisted beta headers from the incoming request to forward
6056
+ * to the Copilot API. VS Code sends these to enable extended features
6057
+ * like thinking, context management, and advanced tool use.
6058
+ */
6059
+ function extractBetaHeaders(c) {
6060
+ const headers = {};
6061
+ const anthropicBeta = c.req.header("anthropic-beta");
6062
+ if (anthropicBeta) {
6063
+ const filtered = filterBetaHeader(anthropicBeta);
6064
+ if (filtered) headers["anthropic-beta"] = filtered;
6065
+ }
6066
+ return headers;
6067
+ }
6068
+ /**
6069
+ * Extract the text content from the last user message for web search.
6070
+ * Handles both string content and content block arrays (multimodal).
6071
+ */
6072
+ function extractUserQuery$1(messages) {
6073
+ for (let i = messages.length - 1; i >= 0; i--) {
6074
+ const msg = messages[i];
6075
+ if (msg.role === "user") {
6076
+ if (typeof msg.content === "string") return msg.content;
6077
+ if (Array.isArray(msg.content)) {
6078
+ const textBlock = msg.content.find((block) => block.type === "text");
6079
+ if (textBlock?.text) return textBlock.text;
6080
+ }
6081
+ }
6082
+ }
6083
+ }
6084
+ /**
6085
+ * Check if any user message contains tool_result content blocks,
6086
+ * indicating a follow-up turn where we should skip web search.
6087
+ * In Anthropic format, tool results are content blocks inside user messages,
6088
+ * NOT separate role: "tool" messages like in OpenAI format.
6089
+ */
6090
+ function hasToolResultContent(messages) {
6091
+ return messages.some((msg) => Array.isArray(msg.content) && msg.content.some((block) => block.type === "tool_result"));
6092
+ }
6093
+ /**
6094
+ * Inject web search results into the Anthropic system field.
6095
+ * Handles three cases: absent, string, or array of content blocks.
6096
+ * When array, prepends without cache_control to preserve existing directives.
6097
+ */
6098
+ function injectSearchResults(body, searchContext) {
6099
+ if (body.system === void 0 || body.system === null) body.system = searchContext;
6100
+ else if (typeof body.system === "string") body.system = `${searchContext}\n\n${body.system}`;
6101
+ else if (Array.isArray(body.system)) body.system = [{
6102
+ type: "text",
6103
+ text: searchContext
6104
+ }, ...body.system];
6105
+ }
6106
+ /**
6107
+ * Strip web_search tools from the request and clean up tool_choice.
6108
+ * Returns the modified body object.
6109
+ */
6110
+ function stripWebSearchTool(body) {
6111
+ if (!body.tools) return;
6112
+ const tools = body.tools.filter((tool) => !isWebSearchTool$1(tool));
6113
+ body.tools = tools;
6114
+ if (tools.length === 0) {
6115
+ body.tools = void 0;
6116
+ body.tool_choice = void 0;
6117
+ } else if (body.tool_choice && typeof body.tool_choice === "object" && body.tool_choice.type === "tool") {
6118
+ const choiceName = body.tool_choice.name;
6119
+ if (choiceName && !tools.some((tool) => tool && typeof tool === "object" && tool.name === choiceName)) body.tool_choice = { type: "auto" };
6120
+ }
6121
+ }
6122
+ /**
6123
+ * Strip the injected `__anthropic_advisor` tool (and any Anthropic-native
6124
+ * `advisor_*` typed tool) from a request body. Used ONLY on the non-Claude
6125
+ * shim path: ADVISOR's server-side translate-loop (buildAdvisorStream) lives
6126
+ * on the native /v1/messages route, so a non-Claude model has no handler for
6127
+ * the tool and it must be removed before forwarding — otherwise the model
6128
+ * could emit a tool_use that nothing fulfils. Mirrors stripWebSearchTool's
6129
+ * tool_choice cleanup. Returns the original string (same reference) when
6130
+ * nothing was removed.
6131
+ */
6132
+ function stripAdvisorTool(rawBody) {
6133
+ let body;
6134
+ try {
6135
+ body = JSON.parse(rawBody);
6136
+ } catch {
6137
+ return rawBody;
6138
+ }
6139
+ if (!Array.isArray(body.tools)) return rawBody;
6140
+ const original = body.tools;
6141
+ const tools = original.filter((tool) => {
6142
+ if (typeof tool !== "object" || tool === null) return true;
6143
+ if (tool.name === ADVISOR_INTERNAL_TOOL_NAME) return false;
6144
+ const type = tool.type;
6145
+ return typeof type !== "string" || !type.startsWith("advisor_");
6146
+ });
6147
+ if (tools.length === original.length) return rawBody;
6148
+ if (tools.length === 0) {
6149
+ body.tools = void 0;
6150
+ body.tool_choice = void 0;
6151
+ } else {
6152
+ body.tools = tools;
6153
+ if (body.tool_choice && typeof body.tool_choice === "object" && body.tool_choice.type === "tool") {
6154
+ const choiceName = body.tool_choice.name;
6155
+ if (choiceName && !tools.some((tool) => tool && typeof tool === "object" && tool.name === choiceName)) body.tool_choice = { type: "auto" };
6156
+ }
6157
+ }
6158
+ return JSON.stringify(body);
6159
+ }
6160
+ /**
6161
+ * Process web search if the request contains a web_search tool.
6162
+ * Performs the search, injects results into system, and strips the tool.
6163
+ * Returns the (possibly modified) body string to forward.
6164
+ */
6165
+ async function processWebSearch(rawBody) {
6166
+ if (!rawBody.includes("web_search")) return rawBody;
6167
+ let body;
6168
+ try {
6169
+ body = JSON.parse(rawBody);
6170
+ } catch {
6171
+ return rawBody;
6172
+ }
6173
+ if (!body.tools?.some((tool) => isWebSearchTool$1(tool))) return rawBody;
6174
+ const messages = body.messages ?? [];
6175
+ const query = hasToolResultContent(messages) ? void 0 : extractUserQuery$1(messages);
6176
+ if (query) try {
6177
+ const results = await searchWeb(query);
6178
+ const searchContext = [
6179
+ "[Web Search Results]",
6180
+ results.content,
6181
+ "",
6182
+ results.references.map((r) => `- [${r.title}](${r.url})`).join("\n"),
6183
+ "[End Web Search Results]"
6184
+ ].join("\n");
6185
+ injectSearchResults(body, searchContext);
6186
+ } catch (error) {
6187
+ consola.warn("Web search failed, continuing without results:", error);
6188
+ }
6189
+ stripWebSearchTool(body);
6190
+ return JSON.stringify(body);
6191
+ }
6192
+ async function handleCompletion(c) {
6193
+ const startTime = Date.now();
6194
+ await checkRateLimit(state);
6195
+ const rawBody = await c.req.text();
6196
+ const debugEnabled = consola.level >= 4;
6197
+ if (debugEnabled) consola.debug("Anthropic request body:", rawBody.slice(0, 2e3));
6198
+ if (process.env.GH_ROUTER_LOG_FIELDS === "1") {
6199
+ let parsedForLog = void 0;
6200
+ try {
6201
+ parsedForLog = JSON.parse(rawBody);
6202
+ } catch {}
6203
+ logRequestFields({
6204
+ path: c.req.path,
6205
+ body: parsedForLog,
6206
+ betaHeader: c.req.header("anthropic-beta")
6207
+ });
6208
+ }
6209
+ if (state.manualApprove) await awaitApproval();
6210
+ const betaHeaders = extractBetaHeaders(c);
6211
+ const advisorEnabled = isAdvisorRequested(c.req.header("anthropic-beta"));
6212
+ let finalBody = await processWebSearch(rawBody);
6213
+ finalBody = sanitizeAnthropicBody(finalBody);
6214
+ if (advisorEnabled) {
6215
+ finalBody = injectAdvisorTool(finalBody);
6216
+ consola.info("ADVISOR enabled for this request — injecting __anthropic_advisor tool; will translate tool_use → server_tool_use{advisor} on the SSE stream");
6217
+ }
6218
+ if (finalBody.includes("\"mcp_servers\"")) try {
6219
+ const probe = JSON.parse(finalBody);
6220
+ if (Array.isArray(probe.mcp_servers) && probe.mcp_servers.length > 0) return c.json({
6221
+ type: "error",
6222
+ error: {
6223
+ type: "invalid_request_error",
6224
+ 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)"
6225
+ }
6226
+ }, 400);
6227
+ } catch {}
4820
6228
  const { body: resolvedBody, originalModel, resolvedModel, selectedModel } = resolveModelInBody$1(finalBody);
4821
6229
  const modelId = resolvedModel ?? originalModel;
6230
+ const messagesRoute = classifyMessagesRoute(modelId, selectedModel, originalModel);
6231
+ if (messagesRoute !== "claude-passthrough") {
6232
+ const shimBody = stripAdvisorTool(resolvedBody);
6233
+ 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).");
6234
+ const shimOpts = {
6235
+ rawBody: shimBody,
6236
+ modelId,
6237
+ model: selectedModel,
6238
+ originalModel,
6239
+ startTime
6240
+ };
6241
+ return messagesRoute === "chat-shim" ? handleNonClaudeChat(c, shimOpts) : handleNonClaudeResponses(c, shimOpts);
6242
+ }
4822
6243
  if (modelId) logEndpointMismatch(modelId, "/v1/messages");
4823
6244
  const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
4824
6245
  const advisorAborter = advisorEnabled ? new AbortController() : void 0;
@@ -4900,13 +6321,14 @@ async function handleCompletion(c) {
4900
6321
  const cappedResult = await readResponseBodyCapped(response, c.req.path, MAX_RESPONSE_BODY_BYTES);
4901
6322
  if (!cappedResult.ok) return c.json(cappedResult.errorResponse, cappedResult.status);
4902
6323
  const responseBody = cappedResult.value;
6324
+ const usage = responseBody.usage;
4903
6325
  logRequest({
4904
6326
  method: "POST",
4905
6327
  path: c.req.path,
4906
6328
  model: originalModel,
4907
6329
  resolvedModel,
4908
- inputTokens: responseBody.usage?.input_tokens,
4909
- outputTokens: responseBody.usage?.output_tokens,
6330
+ inputTokens: usage?.input_tokens,
6331
+ outputTokens: usage?.output_tokens,
4910
6332
  status: response.status
4911
6333
  }, selectedModel, startTime);
4912
6334
  if (debugEnabled) consola.debug("Non-streaming response from Copilot /v1/messages:", JSON.stringify(responseBody).slice(0, 2e3));
@@ -4953,48 +6375,6 @@ function resolveModelInBody$1(rawBody) {
4953
6375
  selectedModel
4954
6376
  };
4955
6377
  }
4956
- const EFFORT_ORDER = [
4957
- "low",
4958
- "medium",
4959
- "high",
4960
- "xhigh"
4961
- ];
4962
- /**
4963
- * Bucket a thinking budget into a Copilot reasoning-effort string.
4964
- * `<2000`→low, `<8000`→medium, `<24000`→high, else→xhigh.
4965
- * Defaults missing/non-numeric budgets to 8000 ("high").
4966
- */
4967
- function bucketEffort(budget) {
4968
- const n = typeof budget === "number" && Number.isFinite(budget) ? budget : 8e3;
4969
- if (n < 2e3) return "low";
4970
- if (n < 8e3) return "medium";
4971
- if (n < 24e3) return "high";
4972
- return "xhigh";
4973
- }
4974
- /**
4975
- * Clamp a bucketed effort to the closest value in `supported`. Ties
4976
- * resolve to the lower-tier option (per EFFORT_ORDER).
4977
- *
4978
- * Iterates EFFORT_ORDER (canonical low→xhigh) so the first match on a
4979
- * given distance is always the lower-tier value, regardless of input
4980
- * order in `supported`.
4981
- */
4982
- function clampEffort(bucketed, supported) {
4983
- if (supported.includes(bucketed)) return bucketed;
4984
- const targetIdx = EFFORT_ORDER.indexOf(bucketed);
4985
- let best;
4986
- let bestDist = Infinity;
4987
- for (let i = 0; i < EFFORT_ORDER.length; i++) {
4988
- const value = EFFORT_ORDER[i];
4989
- if (!supported.includes(value)) continue;
4990
- const dist = Math.abs(i - targetIdx);
4991
- if (dist < bestDist) {
4992
- bestDist = dist;
4993
- best = value;
4994
- }
4995
- }
4996
- return best ?? bucketed;
4997
- }
4998
6378
  /**
4999
6379
  * Clamp `body.output_config.effort` to the model's
5000
6380
  * `capabilities.supports.reasoning_effort` allowlist. Mutates `body`
@@ -5054,8 +6434,9 @@ function translateThinking(body, model) {
5054
6434
  if (!model?.capabilities?.supports?.adaptive_thinking) return false;
5055
6435
  const thinking = body.thinking;
5056
6436
  if (!thinking || typeof thinking !== "object") return false;
5057
- if (thinking.type !== "enabled") return false;
5058
- const bucketed = bucketEffort(thinking.budget_tokens);
6437
+ const t = thinking;
6438
+ if (t.type !== "enabled") return false;
6439
+ const bucketed = bucketEffort(t.budget_tokens);
5059
6440
  const supported = model.capabilities.supports.reasoning_effort;
5060
6441
  const effort = Array.isArray(supported) && supported.length > 0 ? clampEffort(bucketed, supported) : bucketed;
5061
6442
  body.thinking = { type: "adaptive" };
@@ -5077,9 +6458,10 @@ function translateThinking(body, model) {
5077
6458
  function sanitizeCacheControl$1(body) {
5078
6459
  let stripped = false;
5079
6460
  function stripScope(block) {
5080
- if (block.cache_control?.scope !== void 0) {
5081
- delete block.cache_control.scope;
5082
- if (Object.keys(block.cache_control).length === 0) delete block.cache_control;
6461
+ const cc = block.cache_control;
6462
+ if (cc?.scope !== void 0) {
6463
+ delete cc.scope;
6464
+ if (Object.keys(cc).length === 0) delete block.cache_control;
5083
6465
  stripped = true;
5084
6466
  }
5085
6467
  }
@@ -6006,6 +7388,134 @@ function parseSharedArgs(args) {
6006
7388
  };
6007
7389
  }
6008
7390
  /**
7391
+ * Non-Claude models we surface as first-class, selectable rows in Claude
7392
+ * Code's model picker (Phase 3 of native-non-claude-models). The main
7393
+ * agent loop runs on them through the `/v1/messages` translation shim
7394
+ * (`src/lib/anthropic-translate/*`, branched in `routes/messages/handler.ts`)
7395
+ * that forwards non-Claude targets to Copilot `/responses` (gpt) or
7396
+ * `/chat/completions` (gemini). The exact gemini id is
7397
+ * `gemini-3.1-pro-preview` (preview slug — NOT `gemini-3.1-pro`).
7398
+ *
7399
+ * Display labels only: the gateway-model cache schema Claude Code reads is
7400
+ * `{id, display_name?}` per model — there is NO per-model context-window
7401
+ * field, so context accounting for a selected row uses Claude Code's
7402
+ * default window (safe under-accounting: it compacts earlier than the real
7403
+ * 1M/400k window, never overflows). See `seedGatewayModelCache`.
7404
+ */
7405
+ const NATIVE_NON_CLAUDE_MODELS = [
7406
+ {
7407
+ id: "gpt-5.5",
7408
+ displayName: "GPT-5.5"
7409
+ },
7410
+ {
7411
+ id: "gpt-5.3-codex",
7412
+ displayName: "GPT-5.3 Codex"
7413
+ },
7414
+ {
7415
+ id: "gemini-3.5-flash",
7416
+ displayName: "Gemini 3.5 Flash"
7417
+ },
7418
+ {
7419
+ id: "gemini-3.1-pro-preview",
7420
+ displayName: "Gemini 3.1 Pro (preview)"
7421
+ }
7422
+ ];
7423
+ /**
7424
+ * The subset of `NATIVE_NON_CLAUDE_MODELS` actually present in the live
7425
+ * Copilot catalog. License tiers differ (gpt-5.5 needs
7426
+ * pro_plus/business/enterprise/max; gemini-3.5-flash is absent on
7427
+ * edu/individual_trial), so a model missing from the catalog is silently
7428
+ * dropped — the caller then neither enables discovery nor writes a cache
7429
+ * for it, and lesser tiers see the unchanged picker. Pure (reads
7430
+ * `state.models`), so it is unit-testable without side effects.
7431
+ */
7432
+ function nativeSelectableModelsInCatalog() {
7433
+ const catalog = state.models?.data;
7434
+ if (!catalog || catalog.length === 0) return [];
7435
+ const present = new Set(catalog.map((m) => m.id));
7436
+ return NATIVE_NON_CLAUDE_MODELS.filter((m) => present.has(m.id)).map((m) => ({
7437
+ id: m.id,
7438
+ display_name: m.displayName
7439
+ }));
7440
+ }
7441
+ /**
7442
+ * Pre-seed Claude Code's gateway-model discovery cache so the non-Claude
7443
+ * models appear as selectable picker rows WITHOUT the network fetch.
7444
+ *
7445
+ * Verified against the installed Claude Code build (2.1.201): the picker
7446
+ * builder reads `<CLAUDE_CONFIG_DIR>/cache/gateway-models.json`
7447
+ * (schema `{baseUrl: string, fetchedAt: number, models: [{id, display_name?}]}`)
7448
+ * and — when gateway discovery is enabled (first-party auth mode +
7449
+ * `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` set + a non-`api.anthropic.com`
7450
+ * `ANTHROPIC_BASE_URL`, all true for the proxy) — maps each cached model to
7451
+ * a picker row `{value: id, label: display_name}`. Critically, the
7452
+ * cache-READ path applies NO id filter; the `/^(claude|anthropic)/i` filter
7453
+ * lives ONLY in the network-FETCH path. So a pre-seeded cache can carry the
7454
+ * real Copilot ids (`gpt-5.5`, `gemini-3.1-pro-preview`, …) — no `claude-*`
7455
+ * alias needed — and selecting a row sends that real id, which
7456
+ * `resolveModel()` exact-matches and the `/v1/messages` shim routes.
7457
+ *
7458
+ * The network fetch never overwrites this seed: it bails when nonessential
7459
+ * traffic is disabled, and the proxy ALWAYS sets
7460
+ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, so the fetch returns before
7461
+ * it can write. The seed is therefore authoritative for the session.
7462
+ *
7463
+ * `baseUrl` MUST equal the `ANTHROPIC_BASE_URL` Claude Code sees
7464
+ * (`serverUrl`) or the cache is discarded. `configDir` defaults to
7465
+ * `PATHS.CLAUDE_CONFIG_DIR` — the same dir the proxy points
7466
+ * `CLAUDE_CONFIG_DIR` at — so the write target and Claude Code's read
7467
+ * target are identical by construction.
7468
+ *
7469
+ * Best-effort: every failure is swallowed — a missing picker row must never
7470
+ * break launch. This is coupled to Claude Code's internal cache path/schema;
7471
+ * if a future build changes them the read simply ignores the seed and the
7472
+ * rows don't appear (graceful degradation). Returns whether a file was
7473
+ * written (for tests/observability).
7474
+ *
7475
+ * The write is atomic (temp file in the same dir + rename) so a Claude Code
7476
+ * read can never observe a torn/partial JSON (which its safeParse would
7477
+ * reject, dropping the rows). Rename-over-existing is atomic on POSIX and
7478
+ * Windows (libuv MoveFileEx REPLACE_EXISTING).
7479
+ */
7480
+ function seedGatewayModelCache(serverUrl, models$1, configDir = PATHS.CLAUDE_CONFIG_DIR) {
7481
+ if (models$1.length === 0) return false;
7482
+ const cacheDir = nodePath$1.join(configDir, "cache");
7483
+ const target = nodePath$1.join(cacheDir, "gateway-models.json");
7484
+ const tmp = nodePath$1.join(cacheDir, `gateway-models.json.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
7485
+ try {
7486
+ fs$2.mkdirSync(cacheDir, { recursive: true });
7487
+ const payload = {
7488
+ baseUrl: serverUrl,
7489
+ fetchedAt: Date.now(),
7490
+ models: models$1.map((m) => ({
7491
+ id: m.id,
7492
+ display_name: m.display_name
7493
+ }))
7494
+ };
7495
+ fs$2.writeFileSync(tmp, JSON.stringify(payload), "utf-8");
7496
+ fs$2.renameSync(tmp, target);
7497
+ return true;
7498
+ } catch {
7499
+ try {
7500
+ fs$2.rmSync(tmp, { force: true });
7501
+ } catch {}
7502
+ return false;
7503
+ }
7504
+ }
7505
+ /**
7506
+ * Remove any seeded gateway-model cache. Called when the current catalog
7507
+ * carries none of the target models, so a user who has pinned
7508
+ * `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` on cannot surface rows for
7509
+ * models that are no longer available. Best-effort (per-launch config dirs
7510
+ * make a stale file rare, but this closes the pinned-port + catalog-change
7511
+ * seam). Never throws.
7512
+ */
7513
+ function clearGatewayModelCache(configDir = PATHS.CLAUDE_CONFIG_DIR) {
7514
+ try {
7515
+ fs$2.rmSync(nodePath$1.join(configDir, "cache", "gateway-models.json"), { force: true });
7516
+ } catch {}
7517
+ }
7518
+ /**
6009
7519
  * Build environment variables for Claude Code.
6010
7520
  *
6011
7521
  * The parent env is sanitized of every key in `STRIPPED_PARENT_ENV_KEYS`
@@ -6049,16 +7559,17 @@ function getClaudeCodeEnvVars(serverUrl, model) {
6049
7559
  const vars = {
6050
7560
  ANTHROPIC_BASE_URL: serverUrl,
6051
7561
  CLAUDE_CONFIG_DIR: PATHS.CLAUDE_CONFIG_DIR,
6052
- MCP_TIMEOUT: "2100000",
6053
- MCP_TOOL_TIMEOUT: "2100000",
6054
7562
  DISABLE_NON_ESSENTIAL_MODEL_CALLS: "1",
6055
7563
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
6056
7564
  DISABLE_TELEMETRY: "1"
6057
7565
  };
6058
7566
  if (model) vars.ANTHROPIC_MODEL = model;
6059
- if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-sonnet-4-6";
6060
- if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL === void 0) vars.ANTHROPIC_DEFAULT_SONNET_MODEL = "claude-sonnet-4-6";
6061
- if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === void 0) vars.ANTHROPIC_DEFAULT_HAIKU_MODEL = "claude-haiku-4-5";
7567
+ const mcpToolTimeoutMs = String(resolveMcpToolTimeoutMs());
7568
+ if (process.env.MCP_TIMEOUT === void 0) vars.MCP_TIMEOUT = mcpToolTimeoutMs;
7569
+ if (process.env.MCP_TOOL_TIMEOUT === void 0) vars.MCP_TOOL_TIMEOUT = mcpToolTimeoutMs;
7570
+ if (process.env.ANTHROPIC_SMALL_FAST_MODEL === void 0) vars.ANTHROPIC_SMALL_FAST_MODEL = "claude-sonnet-5";
7571
+ if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL === void 0) vars.ANTHROPIC_DEFAULT_SONNET_MODEL = "claude-sonnet-5";
7572
+ if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === void 0) vars.ANTHROPIC_DEFAULT_HAIKU_MODEL = "claude-sonnet-5";
6062
7573
  if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL === void 0) vars.ANTHROPIC_DEFAULT_OPUS_MODEL = "claude-opus-4-8";
6063
7574
  if (process.env.CLAUDE_CODE_PLAN_V2_AGENT_COUNT === void 0) vars.CLAUDE_CODE_PLAN_V2_AGENT_COUNT = "7";
6064
7575
  for (const key of [
@@ -6068,6 +7579,10 @@ function getClaudeCodeEnvVars(serverUrl, model) {
6068
7579
  "CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING",
6069
7580
  "CLAUDE_CODE_ENABLE_TASKS"
6070
7581
  ]) if (process.env[key] === void 0) vars[key] = "1";
7582
+ const nativeModels = nativeSelectableModelsInCatalog();
7583
+ if (nativeModels.length > 0) {
7584
+ 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";
7585
+ } else clearGatewayModelCache();
6071
7586
  if (toolbeltEnabled()) Object.assign(vars, toolbeltPathOverride(process.env, PATHS.TOOLBELT_BIN_DIR));
6072
7587
  return vars;
6073
7588
  }
@@ -6095,60 +7610,116 @@ function getCodexEnvVars(serverUrl) {
6095
7610
 
6096
7611
  //#endregion
6097
7612
  //#region src/claude.ts
7613
+ const claudeArgs = {
7614
+ ...sharedServerArgs,
7615
+ model: {
7616
+ alias: "m",
7617
+ type: "string",
7618
+ description: "Override the default model for Claude Code. Accepts a full slug (e.g. claude-opus-4-7) or an Opus family shorthand (e.g. 4.7, 4.8, 4.6) which expands to the best variant for that family — adding the [1m] suffix when a 1M-context backend is in the catalog."
7619
+ },
7620
+ "codex-mcp": {
7621
+ type: "boolean",
7622
+ default: true,
7623
+ description: "Wire peer-model MCP personas (codex-critic, codex-reviewer, gemini-critic) into the spawned Claude Code session"
7624
+ },
7625
+ "codex-cli": {
7626
+ type: "boolean",
7627
+ default: false,
7628
+ description: "Add a `codex mcp-server` stdio backend so codex-implementer can mutate files. Requires codex CLI 0.129+; gracefully falls back to HTTP-only if absent."
7629
+ },
7630
+ "codex-mcp-only": {
7631
+ type: "boolean",
7632
+ default: false,
7633
+ description: "Pass --strict-mcp-config to claude code so only github-router's MCP servers are loaded (hides user's existing MCP servers)"
7634
+ },
7635
+ stealth: {
7636
+ type: "boolean",
7637
+ default: false,
7638
+ 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."
7639
+ },
7640
+ "trust-gate": {
7641
+ type: "boolean",
7642
+ default: false,
7643
+ description: "Explicitly record consent for the structural Stop-gate in THIS repo (pinned to the repo's root-commit). The gate is ON BY DEFAULT when a harness is detected (consent-by-launching), so this is now mostly redundant; it stays for explicit/scripted use. Disable the gate entirely with GH_ROUTER_DISABLE_STOP_GATE=1."
7644
+ },
7645
+ "no-stop-gate": {
7646
+ type: "boolean",
7647
+ default: false,
7648
+ description: "Disable the structural Stop-gate for THIS session (same effect as GH_ROUTER_DISABLE_STOP_GATE=1). Intended for driven/automated sessions where a blocking Stop hook would hang the turn-end while a fleet driver waits."
7649
+ },
7650
+ "auto-update": {
7651
+ type: "boolean",
7652
+ default: true,
7653
+ description: "Check for and install the latest Claude Code on launch via `claude update` (throttled to once per hour via ~/.local/share/github-router/last-update-check). `claude update` respects the real install method (native installer or npm), so it never creates a conflicting second install; builds too old to support it fall back to `npm install -g @anthropic-ai/claude-code@latest`. Set to false (--no-auto-update) to check and warn only. Falls back gracefully if claude/npm/network unavailable."
7654
+ },
7655
+ "update-check": {
7656
+ type: "boolean",
7657
+ default: true,
7658
+ 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)."
7659
+ }
7660
+ };
7661
+ /**
7662
+ * Build the argv to forward to the spawned `claude` child from citty's
7663
+ * rawArgs (every token after the `claude` subcommand). citty is non-strict,
7664
+ * so an unknown flag such as `--print`/`-p`… `--output-format` is absorbed
7665
+ * into the parsed `args` object AND its value swallowed, instead of landing
7666
+ * in `args._`; forwarding only `args._` therefore drops headless flags unless
7667
+ * the user wrapped them in `--`. Here we walk rawArgs and forward every token
7668
+ * that is NOT one of github-router's OWN declared flags (or that flag's
7669
+ * consumed value). Everything after a literal `--` is forwarded verbatim,
7670
+ * preserving the prior explicit-passthrough behavior.
7671
+ *
7672
+ * A child flag whose NAME collides with a github-router flag (`-p`/`--port`,
7673
+ * `-v`/`--verbose`, `-m`/`--model`, `-a`/`--account-type`, `-r`/`--rate-limit`,
7674
+ * `-g`/`--github-token`) is owned by github-router; forward it to the child
7675
+ * explicitly after `--` (e.g. `github-router claude -- -p`). Every other Claude
7676
+ * flag (`--print`, `--output-format`, `--resume`, `--continue`, …) flows
7677
+ * through automatically.
7678
+ */
7679
+ function collectChildPassthroughArgs(rawArgs, argsDef) {
7680
+ const known = /* @__PURE__ */ new Set();
7681
+ const stringTyped = /* @__PURE__ */ new Set();
7682
+ for (const [name$1, def] of Object.entries(argsDef)) {
7683
+ const rawAlias = "alias" in def ? def.alias : void 0;
7684
+ const aliases = rawAlias === void 0 ? [] : Array.isArray(rawAlias) ? rawAlias : [rawAlias];
7685
+ for (const n of [name$1, ...aliases]) {
7686
+ known.add(n);
7687
+ if (def.type === "string") stringTyped.add(n);
7688
+ }
7689
+ }
7690
+ const forwarded = [];
7691
+ for (let i = 0; i < rawArgs.length; i++) {
7692
+ const tok = rawArgs[i];
7693
+ if (tok === "--") {
7694
+ forwarded.push(...rawArgs.slice(i + 1));
7695
+ break;
7696
+ }
7697
+ if (tok === "-" || !tok.startsWith("-")) {
7698
+ forwarded.push(tok);
7699
+ continue;
7700
+ }
7701
+ const doubleDash = tok.startsWith("--");
7702
+ const afterDashes = tok.slice(doubleDash ? 2 : 1);
7703
+ const eq = afterDashes.indexOf("=");
7704
+ const rawName = eq >= 0 ? afterDashes.slice(0, eq) : afterDashes;
7705
+ const hasInlineValue = eq >= 0;
7706
+ const negated = doubleDash && rawName.startsWith("no-");
7707
+ const baseName = negated ? rawName.slice(3) : rawName;
7708
+ if (!(known.has(rawName) || negated && known.has(baseName))) {
7709
+ forwarded.push(tok);
7710
+ continue;
7711
+ }
7712
+ if (!negated && !hasInlineValue && stringTyped.has(rawName) && i + 1 < rawArgs.length && rawArgs[i + 1] !== "--" && !rawArgs[i + 1].startsWith("-")) i++;
7713
+ }
7714
+ return forwarded;
7715
+ }
6098
7716
  const claude = defineCommand({
6099
7717
  meta: {
6100
7718
  name: "claude",
6101
7719
  description: "Start the proxy server and launch Claude Code"
6102
7720
  },
6103
- args: {
6104
- ...sharedServerArgs,
6105
- model: {
6106
- alias: "m",
6107
- type: "string",
6108
- description: "Override the default model for Claude Code. Accepts a full slug (e.g. claude-opus-4-7) or an Opus family shorthand (e.g. 4.7, 4.8, 4.6) which expands to the best variant for that family — adding the [1m] suffix when a 1M-context backend is in the catalog."
6109
- },
6110
- "codex-mcp": {
6111
- type: "boolean",
6112
- default: true,
6113
- description: "Wire peer-model MCP personas (codex-critic, codex-reviewer, gemini-critic) into the spawned Claude Code session"
6114
- },
6115
- "codex-cli": {
6116
- type: "boolean",
6117
- default: false,
6118
- description: "Add a `codex mcp-server` stdio backend so codex-implementer can mutate files. Requires codex CLI 0.129+; gracefully falls back to HTTP-only if absent."
6119
- },
6120
- "codex-mcp-only": {
6121
- type: "boolean",
6122
- default: false,
6123
- description: "Pass --strict-mcp-config to claude code so only github-router's MCP servers are loaded (hides user's existing MCP servers)"
6124
- },
6125
- stealth: {
6126
- type: "boolean",
6127
- default: false,
6128
- 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."
6129
- },
6130
- "trust-gate": {
6131
- type: "boolean",
6132
- default: false,
6133
- description: "Explicitly record consent for the structural Stop-gate in THIS repo (pinned to the repo's root-commit). The gate is ON BY DEFAULT when a harness is detected (consent-by-launching), so this is now mostly redundant; it stays for explicit/scripted use. Disable the gate entirely with GH_ROUTER_DISABLE_STOP_GATE=1."
6134
- },
6135
- "no-stop-gate": {
6136
- type: "boolean",
6137
- default: false,
6138
- description: "Disable the structural Stop-gate for THIS session (same effect as GH_ROUTER_DISABLE_STOP_GATE=1). Intended for driven/automated sessions where a blocking Stop hook would hang the turn-end while a fleet driver waits."
6139
- },
6140
- "auto-update": {
6141
- type: "boolean",
6142
- default: true,
6143
- description: "Check for and install the latest Claude Code on launch via `claude update` (throttled to once per hour via ~/.local/share/github-router/last-update-check). `claude update` respects the real install method (native installer or npm), so it never creates a conflicting second install; builds too old to support it fall back to `npm install -g @anthropic-ai/claude-code@latest`. Set to false (--no-auto-update) to check and warn only. Falls back gracefully if claude/npm/network unavailable."
6144
- },
6145
- "update-check": {
6146
- type: "boolean",
6147
- default: true,
6148
- 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)."
6149
- }
6150
- },
6151
- async run({ args }) {
7721
+ args: claudeArgs,
7722
+ async run({ args, rawArgs }) {
6152
7723
  if (!process$1.stdout.isTTY) {
6153
7724
  consola.error("The claude subcommand requires a TTY (interactive terminal).");
6154
7725
  process$1.exit(1);
@@ -6217,7 +7788,7 @@ const claude = defineCommand({
6217
7788
  const banner = chosenSlug === resolvedSlug ? chosenSlug : `${chosenSlug} → ${resolvedSlug}`;
6218
7789
  process$1.stderr.write(`Server ready on ${serverUrl}, launching Claude Code (${banner})...\n`);
6219
7790
  const envVars = getClaudeCodeEnvVars(serverUrl, chosenSlug);
6220
- const extraArgs = args._ ?? [];
7791
+ const extraArgs = collectChildPassthroughArgs(rawArgs, claudeArgs);
6221
7792
  if (toolbeltEnabled()) {
6222
7793
  provisionToolbelt().catch((err) => consola.debug("Toolbelt provisioning failed:", err));
6223
7794
  const toolbeltLine = buildToolbeltAwareness(availableToolCommands());
@@ -6260,7 +7831,8 @@ const claude = defineCommand({
6260
7831
  geminiAvailable,
6261
7832
  groupKeys,
6262
7833
  workerToolsAvailable: workerToolsEnabled(),
6263
- browseAvailable: browseAgentEnabled()
7834
+ browseAvailable: browseAgentEnabled(),
7835
+ implementerModel: implementerSubagentModel()
6264
7836
  });
6265
7837
  state.peerMcpNonce = runtime.nonce;
6266
7838
  envVars.GH_ROUTER_HOOK_MCP_URL = serverUrl;
@@ -7411,9 +8983,10 @@ async function readPayload() {
7411
8983
  * live tree itself for anything beyond it, so a giant diff never blows the model
7412
8984
  * window. The Stop hook already caps the captured diff at 2 MiB. */
7413
8985
  const MAX_EMBEDDED_DIFF_BYTES = 200 * 1024;
7414
- /** Wall-clock the reviewer may take. Sized at the worker engine's own 30-min cap
7415
- * plus headroom this process is detached, so nothing waits on it; the bound
7416
- * only stops a hung request from lingering forever. */
8986
+ /** Wall-clock the stop-gate reviewer may take. This is INDEPENDENT of the
8987
+ * autonomous worker's wall-clock cap (`DEFAULT_MAX_WALLCLOCK_MS`, now 6h)
8988
+ * it bounds this one detached review request. Nothing waits on this process,
8989
+ * so the bound only stops a hung request from lingering forever. */
7417
8990
  const REVIEW_TIMEOUT_MS = 2100 * 1e3;
7418
8991
  function buildReviewBrief(payload) {
7419
8992
  const diff = payload.diff.length > MAX_EMBEDDED_DIFF_BYTES ? `${payload.diff.slice(0, MAX_EMBEDDED_DIFF_BYTES)}\n\n[diff truncated at ${MAX_EMBEDDED_DIFF_BYTES} bytes — read the files directly for the rest]` : payload.diff;