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/browser-ext/manifest.json +1 -1
- package/dist/engine-DHFa2_hK.js +6 -0
- package/dist/{lifecycle-CbcVnn0e.js → lifecycle-BAug_A5A.js} +2 -2
- package/dist/{lifecycle-C4k0pEvn.js → lifecycle-BUXxiltc.js} +2 -2
- package/dist/{lifecycle-C4k0pEvn.js.map → lifecycle-BUXxiltc.js.map} +1 -1
- package/dist/{lifecycle-VTQI28wT.js → lifecycle-ChPBRt6K.js} +2 -2
- package/dist/{lifecycle-VTQI28wT.js.map → lifecycle-ChPBRt6K.js.map} +1 -1
- package/dist/{lifecycle-CsxCFx1b.js → lifecycle-L7Y7RJl4.js} +2 -2
- package/dist/main.js +1807 -234
- package/dist/main.js.map +1 -1
- package/dist/{paths-Bt7sqiVr.js → paths-CTlT1nTo.js} +6 -3
- package/dist/paths-CTlT1nTo.js.map +1 -0
- package/dist/{paths-C9Nkr_NK.js → paths-DN3O54iE.js} +1 -1
- package/dist/{peer-mcp-personas-DMM1akDa.js → peer-mcp-personas-Bfq3FPKc.js} +373 -105
- package/dist/peer-mcp-personas-Bfq3FPKc.js.map +1 -0
- package/package.json +1 -1
- package/dist/engine-BP5EnZ-n.js +0 -6
- package/dist/paths-Bt7sqiVr.js.map +0 -1
- package/dist/peer-mcp-personas-DMM1akDa.js.map +0 -1
package/dist/main.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
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-
|
|
4
|
-
import { c as killManagedTree, d as runCommandCapture, f as runCommandVoid, l as parseBoolEnv, s as killChildProcessTree, u as resolveExecutable } from "./lifecycle-
|
|
5
|
-
import { a as sweepRegistry } from "./lifecycle-
|
|
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
|
|
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-
|
|
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"
|
|
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-
|
|
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
|
|
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
|
|
2988
|
-
|
|
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.
|
|
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/
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
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
|
-
*
|
|
4702
|
-
*
|
|
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
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
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
|
-
*
|
|
4718
|
-
*
|
|
4719
|
-
*
|
|
4720
|
-
*
|
|
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
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4876
|
+
const EFFORT_ORDER = [
|
|
4877
|
+
"low",
|
|
4878
|
+
"medium",
|
|
4879
|
+
"high",
|
|
4880
|
+
"xhigh"
|
|
4881
|
+
];
|
|
4725
4882
|
/**
|
|
4726
|
-
*
|
|
4727
|
-
*
|
|
4728
|
-
*
|
|
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
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
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
|
-
*
|
|
4740
|
-
*
|
|
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
|
|
4743
|
-
if (
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
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
|
-
*
|
|
4755
|
-
*
|
|
4756
|
-
*
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
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
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
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
|
-
|
|
4782
|
-
return
|
|
5107
|
+
flushUser();
|
|
5108
|
+
return out;
|
|
4783
5109
|
}
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
const
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
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
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
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
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
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:
|
|
4909
|
-
outputTokens:
|
|
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
|
-
|
|
5058
|
-
|
|
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
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
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
|
-
|
|
6060
|
-
if (process.env.
|
|
6061
|
-
if (process.env.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
7415
|
-
*
|
|
7416
|
-
*
|
|
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;
|