copilot-relay 0.0.7 → 0.0.9
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/README.md +8 -1
- package/config.default.yaml +3 -0
- package/dist/main.js +515 -116
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
-
import { inspect } from "node:util";
|
|
4
|
+
import { inspect, promisify } from "node:util";
|
|
5
5
|
import consola from "consola";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path, { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
9
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
11
|
import { events } from "fetch-event-stream";
|
|
11
12
|
import { readFileSync } from "node:fs";
|
|
12
|
-
import {
|
|
13
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
13
14
|
import { Hono } from "hono";
|
|
14
15
|
import { cors } from "hono/cors";
|
|
15
16
|
import { streamSSE } from "hono/streaming";
|
|
@@ -56,6 +57,7 @@ const copilotTokenPath = path.join(appDir, "copilot_token.json");
|
|
|
56
57
|
const githubTokenPath = path.join(appDir, "github_token");
|
|
57
58
|
const logsDir = path.join(appDir, "logs");
|
|
58
59
|
const logPath = path.join(logsDir, "copilot-relay.log");
|
|
60
|
+
const pidPath = path.join(appDir, "copilot-relay.pid");
|
|
59
61
|
const paths = {
|
|
60
62
|
appDir,
|
|
61
63
|
configPath,
|
|
@@ -68,7 +70,8 @@ const paths = {
|
|
|
68
70
|
],
|
|
69
71
|
legacyCopilotTokenPaths,
|
|
70
72
|
logPath,
|
|
71
|
-
logsDir
|
|
73
|
+
logsDir,
|
|
74
|
+
pidPath
|
|
72
75
|
};
|
|
73
76
|
async function ensurePaths() {
|
|
74
77
|
await fs.mkdir(paths.appDir, { recursive: true });
|
|
@@ -167,7 +170,7 @@ const standardHeaders = () => ({
|
|
|
167
170
|
accept: "application/json",
|
|
168
171
|
"content-type": "application/json"
|
|
169
172
|
});
|
|
170
|
-
const sleep = (ms) => new Promise((resolve$1) => {
|
|
173
|
+
const sleep$1 = (ms) => new Promise((resolve$1) => {
|
|
171
174
|
setTimeout(resolve$1, ms);
|
|
172
175
|
});
|
|
173
176
|
const readGitHubToken = async () => {
|
|
@@ -245,12 +248,12 @@ const pollAccessToken = async (deviceCode) => {
|
|
|
245
248
|
})
|
|
246
249
|
});
|
|
247
250
|
if (!response.ok) {
|
|
248
|
-
await sleep(sleepDuration);
|
|
251
|
+
await sleep$1(sleepDuration);
|
|
249
252
|
continue;
|
|
250
253
|
}
|
|
251
254
|
const json = await response.json();
|
|
252
255
|
if (json.access_token) return json.access_token;
|
|
253
|
-
await sleep(sleepDuration);
|
|
256
|
+
await sleep$1(sleepDuration);
|
|
254
257
|
}
|
|
255
258
|
};
|
|
256
259
|
const getGitHubUser = async (githubToken, vsCodeVersion) => {
|
|
@@ -372,6 +375,7 @@ const defaultConfig = {
|
|
|
372
375
|
opusModel: "claude-opus-4.8",
|
|
373
376
|
port: 4142,
|
|
374
377
|
thinkEffort: defaultReasoningEffort,
|
|
378
|
+
upstreamTimeoutSeconds: 180,
|
|
375
379
|
webSearchBackend: void 0
|
|
376
380
|
};
|
|
377
381
|
const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
|
|
@@ -403,6 +407,12 @@ const normalizeThinkEffort = (value) => {
|
|
|
403
407
|
const normalized = value.toLowerCase() === "minimal" ? "low" : value.toLowerCase();
|
|
404
408
|
return isReasoningEffort(normalized) ? normalized : void 0;
|
|
405
409
|
};
|
|
410
|
+
const normalizeUpstreamTimeoutSeconds = (value) => {
|
|
411
|
+
if (value === void 0) return;
|
|
412
|
+
const timeout = normalizePositiveInteger(value);
|
|
413
|
+
if (timeout === void 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a positive integer");
|
|
414
|
+
return timeout;
|
|
415
|
+
};
|
|
406
416
|
const readRawConfig = async () => {
|
|
407
417
|
try {
|
|
408
418
|
return parseConfigYaml(await fs.readFile(paths.configPath, "utf8"));
|
|
@@ -476,6 +486,10 @@ const parseConfigYaml = (content) => {
|
|
|
476
486
|
case "think_effort":
|
|
477
487
|
config.thinkEffort = unquoteYamlScalar(value);
|
|
478
488
|
break;
|
|
489
|
+
case "upstreamTimeoutSeconds":
|
|
490
|
+
case "upstream_timeout_seconds":
|
|
491
|
+
config.upstreamTimeoutSeconds = unquoteYamlScalar(value);
|
|
492
|
+
break;
|
|
479
493
|
case "webSearchBackend":
|
|
480
494
|
case "web_search_backend":
|
|
481
495
|
config.webSearchBackend = unquoteYamlScalar(value);
|
|
@@ -522,6 +536,9 @@ const serializeConfig = (config) => [
|
|
|
522
536
|
"# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh.",
|
|
523
537
|
`thinkEffort: ${config.thinkEffort}`,
|
|
524
538
|
"",
|
|
539
|
+
"# Max seconds to wait for a single Claude request's upstream Copilot calls.",
|
|
540
|
+
`upstreamTimeoutSeconds: ${config.upstreamTimeoutSeconds}`,
|
|
541
|
+
"",
|
|
525
542
|
"# Copilot model used for bridge-managed Claude WebSearch. Empty uses gptModel.",
|
|
526
543
|
`webSearchBackend: ${config.webSearchBackend ?? ""}`,
|
|
527
544
|
"",
|
|
@@ -542,6 +559,7 @@ async function readAppConfig() {
|
|
|
542
559
|
const logRetentionDays$1 = normalizePositiveInteger(raw.logRetentionDays);
|
|
543
560
|
const port = normalizePort(raw.port);
|
|
544
561
|
const thinkEffort = normalizeThinkEffort(raw.thinkEffort);
|
|
562
|
+
const upstreamTimeoutSeconds = normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds);
|
|
545
563
|
const config = {
|
|
546
564
|
claudeSetup: claudeSetup ?? defaultConfig.claudeSetup,
|
|
547
565
|
copilotBaseUrl: normalizeString(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
|
|
@@ -552,6 +570,7 @@ async function readAppConfig() {
|
|
|
552
570
|
opusModel: normalizeString(raw.opusModel) ?? defaultConfig.opusModel,
|
|
553
571
|
port: port ?? defaultConfig.port,
|
|
554
572
|
thinkEffort: thinkEffort ?? defaultConfig.thinkEffort,
|
|
573
|
+
upstreamTimeoutSeconds: upstreamTimeoutSeconds ?? defaultConfig.upstreamTimeoutSeconds,
|
|
555
574
|
webSearchBackend: normalizeString(raw.webSearchBackend)
|
|
556
575
|
};
|
|
557
576
|
await writeConfig(config);
|
|
@@ -590,6 +609,7 @@ const readProxyConfig = (config) => ({
|
|
|
590
609
|
copilotToken: void 0,
|
|
591
610
|
host: config.host,
|
|
592
611
|
port: config.port,
|
|
612
|
+
upstreamTimeoutMs: config.upstreamTimeoutSeconds * 1e3,
|
|
593
613
|
vsCodeVersion: vscodeVersion,
|
|
594
614
|
webSearchBackend: config.webSearchBackend
|
|
595
615
|
});
|
|
@@ -610,6 +630,179 @@ const auth = defineCommand({
|
|
|
610
630
|
}
|
|
611
631
|
});
|
|
612
632
|
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/lib/lifecycle.ts
|
|
635
|
+
const execFileAsync = promisify(execFile);
|
|
636
|
+
const stopTimeoutMs = 5e3;
|
|
637
|
+
const stopPollMs = 100;
|
|
638
|
+
const isNodeErrno = (error) => typeof error === "object" && error !== null && "code" in error;
|
|
639
|
+
const sleep = (ms) => new Promise((resolve$1) => {
|
|
640
|
+
setTimeout(resolve$1, ms);
|
|
641
|
+
});
|
|
642
|
+
const parsePid = (value) => {
|
|
643
|
+
const pid = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value.trim(), 10) : NaN;
|
|
644
|
+
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
645
|
+
};
|
|
646
|
+
const readCommandOutput = async (file, args) => {
|
|
647
|
+
try {
|
|
648
|
+
const { stdout } = await execFileAsync(file, args, {
|
|
649
|
+
maxBuffer: 1024 * 1024,
|
|
650
|
+
windowsHide: true
|
|
651
|
+
});
|
|
652
|
+
return String(stdout);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (typeof error === "object" && error !== null && "stdout" in error && (typeof error.stdout === "string" || Buffer.isBuffer(error.stdout))) return String(error.stdout);
|
|
655
|
+
return "";
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
const parsePidLines = (output) => output.split(/\r?\n/).map((line) => parsePid(line)).filter((pid) => pid !== void 0);
|
|
659
|
+
const isRelayStartProcess = (command, cwd) => {
|
|
660
|
+
const normalizedCommand = command.replaceAll("\\", "/");
|
|
661
|
+
const normalizedCwd = cwd?.replaceAll("\\", "/");
|
|
662
|
+
if (!/(?:^|\s)start(?:\s|$)/.test(normalizedCommand)) return false;
|
|
663
|
+
if (normalizedCommand.includes("copilot-relay")) return true;
|
|
664
|
+
const usesNode = /(?:^|\s|\/)node(?:\.exe)?(?:\s|$)/i.test(normalizedCommand);
|
|
665
|
+
const usesDistMain = /(?:^|\s|\/)dist\/main\.js(?:\s|$)/.test(normalizedCommand);
|
|
666
|
+
return Boolean(usesNode && usesDistMain && normalizedCwd && normalizedCwd.split("/").some((part) => part.includes("copilot-relay")));
|
|
667
|
+
};
|
|
668
|
+
const isProcessAlive = (pid) => {
|
|
669
|
+
try {
|
|
670
|
+
process.kill(pid, 0);
|
|
671
|
+
return true;
|
|
672
|
+
} catch (error) {
|
|
673
|
+
return isNodeErrno(error) && error.code === "EPERM";
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
const readRelayPidFile = async () => {
|
|
677
|
+
try {
|
|
678
|
+
const trimmed = (await fs.readFile(paths.pidPath, "utf8")).trim();
|
|
679
|
+
if (!trimmed) return;
|
|
680
|
+
if (trimmed.startsWith("{")) return parsePid(JSON.parse(trimmed).pid);
|
|
681
|
+
return parsePid(trimmed);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
if (isNodeErrno(error) && error.code === "ENOENT") return;
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
const writeRelayPidFile = async (config) => {
|
|
688
|
+
await fs.mkdir(paths.appDir, { recursive: true });
|
|
689
|
+
const payload = {
|
|
690
|
+
host: config.host,
|
|
691
|
+
pid: process.pid,
|
|
692
|
+
port: config.port,
|
|
693
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
694
|
+
};
|
|
695
|
+
await fs.writeFile(paths.pidPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
|
|
696
|
+
};
|
|
697
|
+
const clearRelayPidFile = async (pid = process.pid, options = {}) => {
|
|
698
|
+
const currentPid = await readRelayPidFile();
|
|
699
|
+
if (!options.force && currentPid !== void 0 && currentPid !== pid) return;
|
|
700
|
+
await fs.unlink(paths.pidPath).catch((error) => {
|
|
701
|
+
if (!isNodeErrno(error) || error.code !== "ENOENT") throw error;
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
const getProcessCommand = async (pid) => {
|
|
705
|
+
if (process.platform === "win32") return (await readCommandOutput("powershell.exe", [
|
|
706
|
+
"-NoProfile",
|
|
707
|
+
"-Command",
|
|
708
|
+
`Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine`
|
|
709
|
+
])).trim() || void 0;
|
|
710
|
+
return (await readCommandOutput("ps", [
|
|
711
|
+
"-p",
|
|
712
|
+
String(pid),
|
|
713
|
+
"-o",
|
|
714
|
+
"command="
|
|
715
|
+
])).trim() || void 0;
|
|
716
|
+
};
|
|
717
|
+
const getProcessCwd = async (pid) => {
|
|
718
|
+
if (process.platform === "win32") return;
|
|
719
|
+
return (await readCommandOutput("lsof", [
|
|
720
|
+
"-a",
|
|
721
|
+
"-p",
|
|
722
|
+
String(pid),
|
|
723
|
+
"-d",
|
|
724
|
+
"cwd",
|
|
725
|
+
"-Fn"
|
|
726
|
+
])).split(/\r?\n/).find((line) => line.startsWith("n"))?.slice(1);
|
|
727
|
+
};
|
|
728
|
+
const getPortListenerPids = async (port) => {
|
|
729
|
+
if (process.platform === "win32") return parsePidLines(await readCommandOutput("powershell.exe", [
|
|
730
|
+
"-NoProfile",
|
|
731
|
+
"-Command",
|
|
732
|
+
`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`
|
|
733
|
+
]));
|
|
734
|
+
return parsePidLines(await readCommandOutput("lsof", [
|
|
735
|
+
"-nP",
|
|
736
|
+
`-iTCP:${port}`,
|
|
737
|
+
"-sTCP:LISTEN",
|
|
738
|
+
"-t"
|
|
739
|
+
]));
|
|
740
|
+
};
|
|
741
|
+
const getProcessList = async () => {
|
|
742
|
+
return (process.platform === "win32" ? await readCommandOutput("powershell.exe", [
|
|
743
|
+
"-NoProfile",
|
|
744
|
+
"-Command",
|
|
745
|
+
"Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"
|
|
746
|
+
]) : await readCommandOutput("ps", ["-axo", "pid=,command="])).split(/\r?\n/).flatMap((line) => {
|
|
747
|
+
const match = process.platform === "win32" ? line.match(/^(\d+)\t(.+)$/) : line.match(/^\s*(\d+)\s+(.+)$/);
|
|
748
|
+
const pid = parsePid(match?.[1]);
|
|
749
|
+
const command = match?.[2]?.trim();
|
|
750
|
+
return pid && command ? [{
|
|
751
|
+
command,
|
|
752
|
+
pid
|
|
753
|
+
}] : [];
|
|
754
|
+
});
|
|
755
|
+
};
|
|
756
|
+
const isRelayPid = async (pid) => {
|
|
757
|
+
if (pid === process.pid || !isProcessAlive(pid)) return false;
|
|
758
|
+
const command = await getProcessCommand(pid);
|
|
759
|
+
if (!command) return false;
|
|
760
|
+
return isRelayStartProcess(command, await getProcessCwd(pid));
|
|
761
|
+
};
|
|
762
|
+
const findRelayProcessIds = async (config) => {
|
|
763
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
764
|
+
const storedPid = await readRelayPidFile();
|
|
765
|
+
if (storedPid) candidates.add(storedPid);
|
|
766
|
+
for (const pid of await getPortListenerPids(config.port)) candidates.add(pid);
|
|
767
|
+
for (const { command, pid } of await getProcessList()) {
|
|
768
|
+
if (pid === process.pid) continue;
|
|
769
|
+
if (command.includes("copilot-relay") || command.includes("dist/main.js")) {
|
|
770
|
+
if (isRelayStartProcess(command, await getProcessCwd(pid))) candidates.add(pid);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const relayPids = [];
|
|
774
|
+
for (const pid of candidates) if (await isRelayPid(pid)) relayPids.push(pid);
|
|
775
|
+
return [...new Set(relayPids)].sort((left, right) => left - right);
|
|
776
|
+
};
|
|
777
|
+
const waitForExit = async (pid, timeoutMs) => {
|
|
778
|
+
const deadline = Date.now() + timeoutMs;
|
|
779
|
+
while (Date.now() < deadline) {
|
|
780
|
+
if (!isProcessAlive(pid)) return true;
|
|
781
|
+
await sleep(stopPollMs);
|
|
782
|
+
}
|
|
783
|
+
return !isProcessAlive(pid);
|
|
784
|
+
};
|
|
785
|
+
const stopProcess = async (pid) => {
|
|
786
|
+
if (!isProcessAlive(pid)) return;
|
|
787
|
+
log.info(`Stopping existing copilot-relay process pid=${pid}`);
|
|
788
|
+
process.kill(pid, "SIGTERM");
|
|
789
|
+
if (await waitForExit(pid, stopTimeoutMs)) return;
|
|
790
|
+
log.error(`copilot-relay process pid=${pid} did not stop; forcing termination`);
|
|
791
|
+
process.kill(pid, "SIGKILL");
|
|
792
|
+
if (!await waitForExit(pid, stopTimeoutMs)) throw new Error(`Could not stop copilot-relay process pid=${pid}`);
|
|
793
|
+
};
|
|
794
|
+
const stopExistingRelay = async (config) => {
|
|
795
|
+
const relayPids = await findRelayProcessIds(config);
|
|
796
|
+
if (relayPids.length === 0) {
|
|
797
|
+
log.info("No existing copilot-relay instance found");
|
|
798
|
+
await clearRelayPidFile(0, { force: true });
|
|
799
|
+
return [];
|
|
800
|
+
}
|
|
801
|
+
for (const pid of relayPids) await stopProcess(pid);
|
|
802
|
+
await clearRelayPidFile(0, { force: true });
|
|
803
|
+
return relayPids;
|
|
804
|
+
};
|
|
805
|
+
|
|
613
806
|
//#endregion
|
|
614
807
|
//#region src/lib/claude-settings.ts
|
|
615
808
|
/**
|
|
@@ -674,13 +867,51 @@ const editorPluginVersion = `copilot-chat/${copilotVersion}`;
|
|
|
674
867
|
const userAgent = `GitHubCopilotChat/${copilotVersion}`;
|
|
675
868
|
const apiVersion = "2025-04-01";
|
|
676
869
|
const maxFetchAttempts = 2;
|
|
870
|
+
const copilotRequestTimeoutMs = 18e4;
|
|
677
871
|
const getCopilotProviderContext = (config) => ({
|
|
678
872
|
baseUrl: config.copilotBaseUrl,
|
|
679
873
|
token: config.copilotToken,
|
|
680
874
|
vsCodeVersion: config.vsCodeVersion
|
|
681
875
|
});
|
|
682
876
|
const shouldRetryResponse = (response) => response.status >= 500 && response.status <= 599;
|
|
683
|
-
const
|
|
877
|
+
const createCopilotRequestSignal = (signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
878
|
+
const signals = [signal, timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0].filter((value) => value !== void 0);
|
|
879
|
+
if (signals.length === 0) return;
|
|
880
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
881
|
+
};
|
|
882
|
+
const getAbortName = (value) => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" ? value.name : void 0;
|
|
883
|
+
const isAbortLikeError = (error) => {
|
|
884
|
+
const name = getAbortName(error);
|
|
885
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
886
|
+
};
|
|
887
|
+
const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
888
|
+
if (!signal?.aborted && !isAbortLikeError(error)) return;
|
|
889
|
+
const timedOut = getAbortName(signal?.reason) === "TimeoutError" || getAbortName(error) === "TimeoutError";
|
|
890
|
+
const message = timedOut ? `Copilot upstream request timed out after ${Math.round(timeoutMs / 1e3)}s.` : "Client request cancelled before Copilot upstream completed.";
|
|
891
|
+
const code = timedOut ? "upstream_timeout" : "request_cancelled";
|
|
892
|
+
return new HTTPError(message, new Response(JSON.stringify({ error: {
|
|
893
|
+
message,
|
|
894
|
+
code
|
|
895
|
+
} }), {
|
|
896
|
+
status: timedOut ? 504 : 499,
|
|
897
|
+
headers: { "content-type": "application/json" }
|
|
898
|
+
}), message);
|
|
899
|
+
};
|
|
900
|
+
const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
901
|
+
try {
|
|
902
|
+
return await response.json();
|
|
903
|
+
} catch (error) {
|
|
904
|
+
throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
const readCopilotText = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
908
|
+
try {
|
|
909
|
+
return await response.text();
|
|
910
|
+
} catch (error) {
|
|
911
|
+
throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
const buildHeaders = (provider, init, options, upstreamRequestId) => {
|
|
684
915
|
const headers = new Headers(init.headers);
|
|
685
916
|
headers.set("authorization", `Bearer ${provider.token}`);
|
|
686
917
|
headers.set("copilot-integration-id", "vscode-chat");
|
|
@@ -689,7 +920,7 @@ const buildHeaders = (provider, init, options) => {
|
|
|
689
920
|
headers.set("user-agent", userAgent);
|
|
690
921
|
headers.set("openai-intent", "conversation-panel");
|
|
691
922
|
headers.set("x-github-api-version", apiVersion);
|
|
692
|
-
headers.set("x-request-id",
|
|
923
|
+
headers.set("x-request-id", upstreamRequestId);
|
|
693
924
|
headers.set("x-vscode-user-agent-library-version", "electron-fetch");
|
|
694
925
|
if (options.vision) headers.set("copilot-vision-request", "true");
|
|
695
926
|
if (options.initiator) headers.set("x-initiator", options.initiator);
|
|
@@ -697,23 +928,48 @@ const buildHeaders = (provider, init, options) => {
|
|
|
697
928
|
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
698
929
|
return headers;
|
|
699
930
|
};
|
|
931
|
+
const formatRequestId = (requestId) => requestId ? `request_id=${requestId} ` : "";
|
|
932
|
+
const logUpstreamLifecycle = (requestId, message) => {
|
|
933
|
+
if (requestId) {
|
|
934
|
+
log.info(`${formatRequestId(requestId)}${message}`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
log.debug(message);
|
|
938
|
+
};
|
|
700
939
|
const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
701
940
|
if (!provider.token) throw new ProxyNotImplementedError("Copilot token is not configured for copilot-relay.");
|
|
941
|
+
const timeoutMs = options.timeoutMs ?? copilotRequestTimeoutMs;
|
|
942
|
+
const signal = createCopilotRequestSignal(options.signal, timeoutMs);
|
|
702
943
|
let lastError;
|
|
703
|
-
for (let attempt = 1; attempt <= maxFetchAttempts; attempt++)
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
944
|
+
for (let attempt = 1; attempt <= maxFetchAttempts; attempt++) {
|
|
945
|
+
const upstreamRequestId = randomUUID();
|
|
946
|
+
try {
|
|
947
|
+
const started = performance.now();
|
|
948
|
+
logUpstreamLifecycle(options.requestId, `send upstream method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
949
|
+
const response = await fetch(`${provider.baseUrl}${path$1}`, {
|
|
950
|
+
...init,
|
|
951
|
+
headers: buildHeaders(provider, init, options, upstreamRequestId),
|
|
952
|
+
signal
|
|
953
|
+
});
|
|
954
|
+
const ms = Math.round(performance.now() - started);
|
|
955
|
+
logUpstreamLifecycle(options.requestId, `return from upstream method=${init.method ?? "GET"} path=${path$1} status=${response.status} ms=${ms} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
956
|
+
log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
|
|
957
|
+
if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
|
|
958
|
+
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
const abortError = toCopilotAbortHTTPError(error, signal, timeoutMs);
|
|
961
|
+
if (abortError) {
|
|
962
|
+
logUpstreamLifecycle(options.requestId, `upstream failed method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
963
|
+
throw abortError;
|
|
964
|
+
}
|
|
965
|
+
logUpstreamLifecycle(options.requestId, `upstream failed method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
966
|
+
lastError = error;
|
|
967
|
+
if (attempt === maxFetchAttempts) {
|
|
968
|
+
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed after ${attempt} attempts upstream_request_id=${upstreamRequestId}`, error);
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`, error);
|
|
972
|
+
}
|
|
717
973
|
}
|
|
718
974
|
throw lastError;
|
|
719
975
|
};
|
|
@@ -1086,6 +1342,7 @@ function getFinishReason(response, hasFunctionCalls) {
|
|
|
1086
1342
|
//#endregion
|
|
1087
1343
|
//#region src/copilot/chat.ts
|
|
1088
1344
|
const usesMaxCompletionTokens = (modelId) => modelId.startsWith("gpt-5");
|
|
1345
|
+
const continuePrefillPrompt$1 = "Continue the assistant response from the previous assistant message.";
|
|
1089
1346
|
const maxUserLength = 64;
|
|
1090
1347
|
const sanitizeReasoningEffortForModel = (_modelId, reasoningEffort) => {
|
|
1091
1348
|
return reasoningEffort ?? void 0;
|
|
@@ -1112,6 +1369,38 @@ const buildRequestPayload = (payload) => {
|
|
|
1112
1369
|
user: sanitizeUserIdentifier(payload.user)
|
|
1113
1370
|
};
|
|
1114
1371
|
};
|
|
1372
|
+
const normalizeFinalAssistantPrefill$1 = (payload) => {
|
|
1373
|
+
const lastMessage = payload.messages.at(-1);
|
|
1374
|
+
if (lastMessage?.role !== "assistant") return payload;
|
|
1375
|
+
const messages = [...payload.messages];
|
|
1376
|
+
if (typeof lastMessage.content !== "string") {
|
|
1377
|
+
messages.pop();
|
|
1378
|
+
return {
|
|
1379
|
+
...payload,
|
|
1380
|
+
messages
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const content = lastMessage.content.trimEnd();
|
|
1384
|
+
if (!content) {
|
|
1385
|
+
messages.pop();
|
|
1386
|
+
return {
|
|
1387
|
+
...payload,
|
|
1388
|
+
messages
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
messages[messages.length - 1] = {
|
|
1392
|
+
...lastMessage,
|
|
1393
|
+
content
|
|
1394
|
+
};
|
|
1395
|
+
messages.push({
|
|
1396
|
+
role: "user",
|
|
1397
|
+
content: continuePrefillPrompt$1
|
|
1398
|
+
});
|
|
1399
|
+
return {
|
|
1400
|
+
...payload,
|
|
1401
|
+
messages
|
|
1402
|
+
};
|
|
1403
|
+
};
|
|
1115
1404
|
const isAgentInitiator = (messages) => messages.some((msg) => msg.role === "assistant" || msg.role === "tool") ? "agent" : "user";
|
|
1116
1405
|
const messagesIncludeImage = (messages) => messages.some((msg) => typeof msg.content !== "string" && msg.content?.some((part) => part.type === "image_url"));
|
|
1117
1406
|
const createChatCompletions = async (config, payload, options = {}) => {
|
|
@@ -1119,53 +1408,67 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1119
1408
|
const requestedModel = options.requestedModel ?? payload.model;
|
|
1120
1409
|
const requestedThinkEffort = options.requestedThinkEffort ?? payload.reasoning_effort ?? "none";
|
|
1121
1410
|
const requestedThinking = options.requestedThinking ?? "none";
|
|
1411
|
+
const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
|
|
1122
1412
|
const upstreamModelId = routeModelId(payload.model);
|
|
1123
1413
|
const upstreamPayload = upstreamModelId === payload.model ? payload : {
|
|
1124
1414
|
...payload,
|
|
1125
1415
|
model: upstreamModelId
|
|
1126
1416
|
};
|
|
1417
|
+
const useResponsesApi = shouldUseResponsesApiForModel(upstreamPayload.model);
|
|
1418
|
+
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill$1(upstreamPayload);
|
|
1127
1419
|
const provider = getCopilotProviderContext(config);
|
|
1128
|
-
const enableVision = messagesIncludeImage(
|
|
1129
|
-
const initiator = isAgentInitiator(
|
|
1130
|
-
const requestPayload = buildRequestPayload(
|
|
1420
|
+
const enableVision = messagesIncludeImage(compatiblePayload.messages);
|
|
1421
|
+
const initiator = isAgentInitiator(compatiblePayload.messages);
|
|
1422
|
+
const requestPayload = buildRequestPayload(compatiblePayload);
|
|
1131
1423
|
log.debug([
|
|
1132
1424
|
"Model request",
|
|
1133
1425
|
`client=${client}`,
|
|
1134
1426
|
`requested_model=${requestedModel}`,
|
|
1135
|
-
`
|
|
1427
|
+
...options.requestId ? [`request_id=${options.requestId}`] : [],
|
|
1428
|
+
`upstream_model=${compatiblePayload.model}`,
|
|
1136
1429
|
`requested_think_effort=${requestedThinkEffort}`,
|
|
1137
1430
|
`requested_thinking=${requestedThinking}`,
|
|
1138
1431
|
`effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
|
|
1139
1432
|
].join(" "));
|
|
1140
1433
|
log.debug("Full request payload", { payload: requestPayload });
|
|
1141
|
-
if (
|
|
1434
|
+
if (useResponsesApi) return createResponses(provider, compatiblePayload, {
|
|
1142
1435
|
vision: enableVision,
|
|
1143
|
-
initiator
|
|
1436
|
+
initiator,
|
|
1437
|
+
requestId: options.requestId,
|
|
1438
|
+
signal,
|
|
1439
|
+
timeoutMs: options.timeoutMs
|
|
1144
1440
|
});
|
|
1145
1441
|
const response = await fetchCopilot(provider, "/chat/completions", {
|
|
1146
1442
|
method: "POST",
|
|
1147
1443
|
headers: {
|
|
1148
|
-
accept:
|
|
1444
|
+
accept: compatiblePayload.stream ? "text/event-stream" : "application/json",
|
|
1149
1445
|
"content-type": "application/json"
|
|
1150
1446
|
},
|
|
1151
1447
|
body: JSON.stringify(requestPayload)
|
|
1152
1448
|
}, {
|
|
1153
1449
|
vision: enableVision,
|
|
1154
|
-
initiator
|
|
1450
|
+
initiator,
|
|
1451
|
+
requestId: options.requestId,
|
|
1452
|
+
signal,
|
|
1453
|
+
timeoutMs: options.timeoutMs
|
|
1155
1454
|
});
|
|
1156
1455
|
if (!response.ok) {
|
|
1157
|
-
if (await shouldRetryWithResponses(response)) return createResponses(provider,
|
|
1456
|
+
if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
|
|
1158
1457
|
vision: enableVision,
|
|
1159
|
-
initiator
|
|
1458
|
+
initiator,
|
|
1459
|
+
requestId: options.requestId,
|
|
1460
|
+
signal,
|
|
1461
|
+
timeoutMs: options.timeoutMs
|
|
1160
1462
|
});
|
|
1161
1463
|
throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
|
|
1162
1464
|
model: payload.model,
|
|
1163
1465
|
request: requestPayload,
|
|
1164
|
-
route: "/chat/completions"
|
|
1165
|
-
|
|
1466
|
+
route: "/chat/completions",
|
|
1467
|
+
requestId: options.requestId
|
|
1468
|
+
}, signal, options.timeoutMs));
|
|
1166
1469
|
}
|
|
1167
|
-
if (
|
|
1168
|
-
return
|
|
1470
|
+
if (compatiblePayload.stream) return events(response);
|
|
1471
|
+
return readCopilotJson(response, signal, options.timeoutMs);
|
|
1169
1472
|
};
|
|
1170
1473
|
async function createResponses(provider, payload, options) {
|
|
1171
1474
|
const requestPayload = buildResponsesRequestPayload(payload, getRequestedReasoningEffort(payload));
|
|
@@ -1178,23 +1481,28 @@ async function createResponses(provider, payload, options) {
|
|
|
1178
1481
|
body: JSON.stringify(requestPayload)
|
|
1179
1482
|
}, {
|
|
1180
1483
|
vision: options.vision,
|
|
1181
|
-
initiator: options.initiator
|
|
1484
|
+
initiator: options.initiator,
|
|
1485
|
+
requestId: options.requestId,
|
|
1486
|
+
signal: options.signal,
|
|
1487
|
+
timeoutMs: options.timeoutMs
|
|
1182
1488
|
});
|
|
1183
1489
|
if (!response.ok) throw new HTTPError("Failed to create responses", response, await logUpstreamError("Failed to create responses", response, {
|
|
1184
1490
|
model: payload.model,
|
|
1185
1491
|
request: requestPayload,
|
|
1186
|
-
route: "/responses"
|
|
1187
|
-
|
|
1492
|
+
route: "/responses",
|
|
1493
|
+
requestId: options.requestId
|
|
1494
|
+
}, options.signal, options.timeoutMs));
|
|
1188
1495
|
if (payload.stream) return translateResponsesStreamToChatCompletionStream(events(response));
|
|
1189
|
-
return translateResponsesToChatCompletion(await response.
|
|
1496
|
+
return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
|
|
1190
1497
|
}
|
|
1191
|
-
async function logUpstreamError(message, response, context) {
|
|
1192
|
-
const errorBody = await response.clone()
|
|
1498
|
+
async function logUpstreamError(message, response, context, signal, timeoutMs) {
|
|
1499
|
+
const errorBody = await readCopilotText(response.clone(), signal, timeoutMs).catch(() => "");
|
|
1193
1500
|
const detail = getUpstreamErrorDetail(response, errorBody);
|
|
1194
|
-
log.error(`${message}: route=${context.route} model=${context.model} status=${response.status}`, {
|
|
1501
|
+
log.error(`${message}: route=${context.route} model=${context.model} status=${response.status}${context.requestId ? ` request_id=${context.requestId}` : ""}`, {
|
|
1195
1502
|
message,
|
|
1196
1503
|
route: context.route,
|
|
1197
1504
|
model: context.model,
|
|
1505
|
+
requestId: context.requestId,
|
|
1198
1506
|
request: context.request,
|
|
1199
1507
|
response: {
|
|
1200
1508
|
status: response.status,
|
|
@@ -1215,9 +1523,9 @@ function getUpstreamErrorDetail(response, body) {
|
|
|
1215
1523
|
return body.slice(0, 240);
|
|
1216
1524
|
}
|
|
1217
1525
|
}
|
|
1218
|
-
async function shouldRetryWithResponses(response) {
|
|
1526
|
+
async function shouldRetryWithResponses(response, signal, timeoutMs) {
|
|
1219
1527
|
try {
|
|
1220
|
-
return (await response.clone()
|
|
1528
|
+
return (await readCopilotJson(response.clone(), signal, timeoutMs)).error?.code === "unsupported_api_for_model";
|
|
1221
1529
|
} catch {
|
|
1222
1530
|
return false;
|
|
1223
1531
|
}
|
|
@@ -1226,12 +1534,17 @@ async function shouldRetryWithResponses(response) {
|
|
|
1226
1534
|
//#endregion
|
|
1227
1535
|
//#region src/lib/preflight.ts
|
|
1228
1536
|
const getUpstreamModelIds = async (config) => {
|
|
1229
|
-
const
|
|
1537
|
+
const provider = getCopilotProviderContext(config);
|
|
1538
|
+
const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
|
|
1539
|
+
const response = await fetchCopilot(provider, "/models", {
|
|
1230
1540
|
method: "GET",
|
|
1231
1541
|
headers: { accept: "application/json" }
|
|
1542
|
+
}, {
|
|
1543
|
+
signal,
|
|
1544
|
+
timeoutMs: config.upstreamTimeoutMs
|
|
1232
1545
|
});
|
|
1233
1546
|
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
1234
|
-
const payload = await response.
|
|
1547
|
+
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
1235
1548
|
return new Set((payload.data ?? []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0));
|
|
1236
1549
|
};
|
|
1237
1550
|
const ensureRequiredModels = async (config) => {
|
|
@@ -1254,7 +1567,8 @@ const validateModelRequest = async (config, model, thinkEffort) => {
|
|
|
1254
1567
|
try {
|
|
1255
1568
|
const response = await createChatCompletions(config, createProbePayload(model), {
|
|
1256
1569
|
client: "generic",
|
|
1257
|
-
requestedModel: model
|
|
1570
|
+
requestedModel: model,
|
|
1571
|
+
timeoutMs: config.upstreamTimeoutMs
|
|
1258
1572
|
});
|
|
1259
1573
|
if (typeof response !== "object" || response === null || !("choices" in response)) throw new Error(`Preflight request for ${model} unexpectedly streamed`);
|
|
1260
1574
|
log.info(`Preflight OK: model=${model} think_effort=${thinkEffort}`);
|
|
@@ -1871,8 +2185,9 @@ const createFailedSearchExecution = (payload, requestedQuery, model, message) =>
|
|
|
1871
2185
|
results: [],
|
|
1872
2186
|
text: message
|
|
1873
2187
|
});
|
|
1874
|
-
const createClaudeWebSearchExecution = async (config, payload, requestedQuery) => {
|
|
2188
|
+
const createClaudeWebSearchExecution = async (config, payload, requestedQuery, options = {}) => {
|
|
1875
2189
|
const backendModel = getWebSearchBackendModel(config);
|
|
2190
|
+
const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
|
|
1876
2191
|
const response = await fetchCopilot(getCopilotProviderContext(config), "/responses", {
|
|
1877
2192
|
method: "POST",
|
|
1878
2193
|
headers: {
|
|
@@ -1880,12 +2195,17 @@ const createClaudeWebSearchExecution = async (config, payload, requestedQuery) =
|
|
|
1880
2195
|
"content-type": "application/json"
|
|
1881
2196
|
},
|
|
1882
2197
|
body: JSON.stringify(buildWebSearchRequestPayload(payload, requestedQuery, backendModel))
|
|
1883
|
-
}, {
|
|
2198
|
+
}, {
|
|
2199
|
+
initiator: "agent",
|
|
2200
|
+
requestId: options.requestId,
|
|
2201
|
+
signal,
|
|
2202
|
+
timeoutMs: options.timeoutMs
|
|
2203
|
+
});
|
|
1884
2204
|
if (!response.ok) {
|
|
1885
|
-
const detail = await response.
|
|
2205
|
+
const detail = await readCopilotText(response, signal, options.timeoutMs).catch(() => "");
|
|
1886
2206
|
return createFailedSearchExecution(payload, requestedQuery, backendModel, [`Copilot web search is not available for model ${backendModel}.`, detail ? `Upstream response: ${detail}` : ""].filter(Boolean).join("\n"));
|
|
1887
2207
|
}
|
|
1888
|
-
const upstream = await response.
|
|
2208
|
+
const upstream = await readCopilotJson(response, signal, options.timeoutMs);
|
|
1889
2209
|
const text = getResponseText(upstream);
|
|
1890
2210
|
const query = getSearchQuery(upstream, requestedQuery);
|
|
1891
2211
|
const results = parseSearchResults(text);
|
|
@@ -2232,7 +2552,9 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
2232
2552
|
}));
|
|
2233
2553
|
claudeRoutes.post("/messages", async (c) => {
|
|
2234
2554
|
const config = c.get("config");
|
|
2555
|
+
const requestId = c.get("requestId");
|
|
2235
2556
|
const claudePayload = await c.req.json();
|
|
2557
|
+
const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
2236
2558
|
log.debug("Full Claude request payload", { payload: claudePayload });
|
|
2237
2559
|
try {
|
|
2238
2560
|
const upstreamModel = translateModelName(claudePayload.model);
|
|
@@ -2245,13 +2567,20 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
2245
2567
|
client: "claude",
|
|
2246
2568
|
requestedModel: claudePayload.model,
|
|
2247
2569
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
2248
|
-
requestedThinking: getClaudeRequestedThinking(claudePayload)
|
|
2570
|
+
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
2571
|
+
requestId,
|
|
2572
|
+
signal: requestSignal,
|
|
2573
|
+
timeoutMs: config.upstreamTimeoutMs
|
|
2249
2574
|
});
|
|
2250
2575
|
if (isNonStreamingResponse(response)) {
|
|
2251
2576
|
const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
|
|
2252
2577
|
let claudeResponse;
|
|
2253
2578
|
if (webSearchToolCall) {
|
|
2254
|
-
const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query
|
|
2579
|
+
const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
|
|
2580
|
+
requestId,
|
|
2581
|
+
signal: requestSignal,
|
|
2582
|
+
timeoutMs: config.upstreamTimeoutMs
|
|
2583
|
+
});
|
|
2255
2584
|
const searchResponse = createClaudeWebSearchResponse(search);
|
|
2256
2585
|
if (search.results.length === 0) claudeResponse = searchResponse;
|
|
2257
2586
|
else {
|
|
@@ -2259,7 +2588,10 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
2259
2588
|
client: "claude",
|
|
2260
2589
|
requestedModel: claudePayload.model,
|
|
2261
2590
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
2262
|
-
requestedThinking: getClaudeRequestedThinking(claudePayload)
|
|
2591
|
+
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
2592
|
+
requestId,
|
|
2593
|
+
signal: requestSignal,
|
|
2594
|
+
timeoutMs: config.upstreamTimeoutMs
|
|
2263
2595
|
});
|
|
2264
2596
|
if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
|
|
2265
2597
|
status: 502,
|
|
@@ -2375,25 +2707,35 @@ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.fl
|
|
|
2375
2707
|
const value = request.headers.get(name);
|
|
2376
2708
|
return value ? [[name, value]] : [];
|
|
2377
2709
|
}));
|
|
2378
|
-
const formatStatusLog = (method, path$1, status, ms, errorMessage) => {
|
|
2379
|
-
const base =
|
|
2710
|
+
const formatStatusLog = (requestId, method, path$1, status, ms, errorMessage) => {
|
|
2711
|
+
const base = `request_id=${requestId} ${method} ${path$1} -> ${status} ${ms}ms`;
|
|
2380
2712
|
return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
|
|
2381
2713
|
};
|
|
2382
2714
|
const createServer = (config) => {
|
|
2383
2715
|
const app = new Hono();
|
|
2384
|
-
app.use("*", cors());
|
|
2385
2716
|
app.use("*", async (c, next) => {
|
|
2717
|
+
const requestId = randomUUID();
|
|
2386
2718
|
c.set("config", config);
|
|
2719
|
+
c.set("requestId", requestId);
|
|
2720
|
+
c.header("x-copilot-relay-request-id", requestId);
|
|
2721
|
+
log.info(`request_id=${requestId} request received method=${c.req.method} path=${c.req.path}`);
|
|
2387
2722
|
const started = performance.now();
|
|
2388
2723
|
try {
|
|
2389
2724
|
await next();
|
|
2390
2725
|
} finally {
|
|
2391
2726
|
const ms = Math.round(performance.now() - started);
|
|
2392
|
-
log.info(formatStatusLog(c.req.method, c.req.path, c.res.status, ms, c.get("requestErrorMessage")));
|
|
2727
|
+
log.info(formatStatusLog(requestId, c.req.method, c.req.path, c.res.status, ms, c.get("requestErrorMessage")));
|
|
2393
2728
|
}
|
|
2394
2729
|
});
|
|
2730
|
+
app.use("*", cors());
|
|
2395
2731
|
app.onError((error, c) => {
|
|
2396
2732
|
c.set("requestErrorMessage", error.message);
|
|
2733
|
+
log.error("Unhandled Claude API request error", {
|
|
2734
|
+
method: c.req.method,
|
|
2735
|
+
path: c.req.path,
|
|
2736
|
+
requestId: c.get("requestId"),
|
|
2737
|
+
error
|
|
2738
|
+
});
|
|
2397
2739
|
return c.json({ error: { message: "Internal server error" } }, 500);
|
|
2398
2740
|
});
|
|
2399
2741
|
app.get("/", (c) => c.json({
|
|
@@ -2408,6 +2750,7 @@ const createServer = (config) => {
|
|
|
2408
2750
|
log.error("Unsupported Claude API request", {
|
|
2409
2751
|
method: c.req.method,
|
|
2410
2752
|
path: c.req.path,
|
|
2753
|
+
requestId: c.get("requestId"),
|
|
2411
2754
|
headers: getLoggedHeaders(c.req.raw),
|
|
2412
2755
|
payload: await readRequestPayloadForLog(c.req.raw)
|
|
2413
2756
|
});
|
|
@@ -2415,76 +2758,131 @@ const createServer = (config) => {
|
|
|
2415
2758
|
});
|
|
2416
2759
|
return app;
|
|
2417
2760
|
};
|
|
2418
|
-
const startServer = (config) =>
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2761
|
+
const startServer = (config) => new Promise((resolve$1, reject) => {
|
|
2762
|
+
const server = createAdaptorServer({
|
|
2763
|
+
fetch: createServer(config).fetch,
|
|
2764
|
+
hostname: config.host,
|
|
2765
|
+
port: config.port
|
|
2766
|
+
});
|
|
2767
|
+
const onError = (error) => {
|
|
2768
|
+
server.off("listening", onListening);
|
|
2769
|
+
reject(error);
|
|
2770
|
+
};
|
|
2771
|
+
const onListening = () => {
|
|
2772
|
+
server.off("error", onError);
|
|
2773
|
+
resolve$1(server);
|
|
2774
|
+
};
|
|
2775
|
+
server.once("error", onError);
|
|
2776
|
+
server.once("listening", onListening);
|
|
2777
|
+
server.listen(config.port, config.host);
|
|
2422
2778
|
});
|
|
2423
2779
|
|
|
2424
2780
|
//#endregion
|
|
2425
2781
|
//#region src/start.ts
|
|
2782
|
+
async function startRelay(appConfig) {
|
|
2783
|
+
appConfig ??= await readAppConfig();
|
|
2784
|
+
setLogLevel(appConfig.logLevel);
|
|
2785
|
+
await cleanupLogs(appConfig.logRetentionDays);
|
|
2786
|
+
const claudeConfigPath$1 = claudeConfigPath;
|
|
2787
|
+
const config = readProxyConfig(appConfig);
|
|
2788
|
+
const applyRuntimeConfig = (nextConfig) => {
|
|
2789
|
+
setLogLevel(nextConfig.logLevel);
|
|
2790
|
+
cleanupLogs(nextConfig.logRetentionDays);
|
|
2791
|
+
runtimeState.debug = nextConfig.logLevel === "debug";
|
|
2792
|
+
runtimeState.thinkEffort = nextConfig.thinkEffort;
|
|
2793
|
+
config.copilotBaseUrl = nextConfig.copilotBaseUrl;
|
|
2794
|
+
config.host = nextConfig.host;
|
|
2795
|
+
config.port = nextConfig.port;
|
|
2796
|
+
config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
|
|
2797
|
+
config.webSearchBackend = nextConfig.webSearchBackend;
|
|
2798
|
+
runtimeState.modelRouting = {
|
|
2799
|
+
gptModel: nextConfig.gptModel,
|
|
2800
|
+
opusModel: nextConfig.opusModel
|
|
2801
|
+
};
|
|
2802
|
+
};
|
|
2803
|
+
applyRuntimeConfig(appConfig);
|
|
2804
|
+
log.info(`Log level: ${appConfig.logLevel}`);
|
|
2805
|
+
log.info(`Think effort: ${appConfig.thinkEffort}`);
|
|
2806
|
+
log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
|
|
2807
|
+
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
2808
|
+
const authSession = await setupProxyAuth(config);
|
|
2809
|
+
try {
|
|
2810
|
+
await validateUpstream(config, appConfig.thinkEffort);
|
|
2811
|
+
} catch (error) {
|
|
2812
|
+
log.error("Startup preflight failed:", error);
|
|
2813
|
+
process.exit(1);
|
|
2814
|
+
}
|
|
2815
|
+
const server = await startServer(config);
|
|
2816
|
+
await writeRelayPidFile(config);
|
|
2817
|
+
log.info(`copilot-relay version: ${appVersion}`);
|
|
2818
|
+
if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
|
|
2819
|
+
else log.error("GitHub user: unavailable");
|
|
2820
|
+
log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
|
|
2821
|
+
log.info(`copilot base url: ${config.copilotBaseUrl}`);
|
|
2822
|
+
const baseUrl = `http://${config.host}:${config.port}`;
|
|
2823
|
+
if (appConfig.claudeSetup) try {
|
|
2824
|
+
const claudeResult = await applyClaudeConfig({
|
|
2825
|
+
baseUrl,
|
|
2826
|
+
configPath: claudeConfigPath$1
|
|
2827
|
+
});
|
|
2828
|
+
if (claudeResult.changed) {
|
|
2829
|
+
log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
|
|
2830
|
+
if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) log.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
|
|
2831
|
+
} else log.info(`claude settings already up to date: ${claudeResult.configPath}`);
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
2834
|
+
}
|
|
2835
|
+
watchAppConfig((nextConfig) => {
|
|
2836
|
+
applyRuntimeConfig(nextConfig);
|
|
2837
|
+
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
|
|
2838
|
+
});
|
|
2839
|
+
const shutdown = (signal) => {
|
|
2840
|
+
log.info(`Received ${signal}; shutting down copilot-relay`);
|
|
2841
|
+
server.close((error) => {
|
|
2842
|
+
if (error) {
|
|
2843
|
+
log.error("Error while shutting down copilot-relay:", error);
|
|
2844
|
+
process.exitCode = 1;
|
|
2845
|
+
}
|
|
2846
|
+
});
|
|
2847
|
+
};
|
|
2848
|
+
process.once("SIGINT", shutdown);
|
|
2849
|
+
process.once("SIGTERM", shutdown);
|
|
2850
|
+
try {
|
|
2851
|
+
await new Promise((resolve$1, reject) => {
|
|
2852
|
+
server.on("close", resolve$1);
|
|
2853
|
+
server.on("error", reject);
|
|
2854
|
+
});
|
|
2855
|
+
} finally {
|
|
2856
|
+
process.off("SIGINT", shutdown);
|
|
2857
|
+
process.off("SIGTERM", shutdown);
|
|
2858
|
+
await clearRelayPidFile();
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2426
2861
|
const start = defineCommand({
|
|
2427
2862
|
meta: {
|
|
2428
2863
|
name: "start",
|
|
2429
2864
|
description: "Start the copilot-relay HTTP server."
|
|
2430
2865
|
},
|
|
2866
|
+
async run() {
|
|
2867
|
+
await startRelay();
|
|
2868
|
+
}
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
//#endregion
|
|
2872
|
+
//#region src/restart.ts
|
|
2873
|
+
const restart = defineCommand({
|
|
2874
|
+
meta: {
|
|
2875
|
+
name: "restart",
|
|
2876
|
+
description: "Stop any existing copilot-relay instance and start a new one."
|
|
2877
|
+
},
|
|
2431
2878
|
async run() {
|
|
2432
2879
|
const appConfig = await readAppConfig();
|
|
2433
2880
|
setLogLevel(appConfig.logLevel);
|
|
2434
2881
|
await cleanupLogs(appConfig.logRetentionDays);
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
cleanupLogs(nextConfig.logRetentionDays);
|
|
2440
|
-
runtimeState.debug = nextConfig.logLevel === "debug";
|
|
2441
|
-
runtimeState.thinkEffort = nextConfig.thinkEffort;
|
|
2442
|
-
config.copilotBaseUrl = nextConfig.copilotBaseUrl;
|
|
2443
|
-
config.host = nextConfig.host;
|
|
2444
|
-
config.port = nextConfig.port;
|
|
2445
|
-
runtimeState.modelRouting = {
|
|
2446
|
-
gptModel: nextConfig.gptModel,
|
|
2447
|
-
opusModel: nextConfig.opusModel
|
|
2448
|
-
};
|
|
2449
|
-
};
|
|
2450
|
-
applyRuntimeConfig(appConfig);
|
|
2451
|
-
log.info(`Log level: ${appConfig.logLevel}`);
|
|
2452
|
-
log.info(`Think effort: ${appConfig.thinkEffort}`);
|
|
2453
|
-
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
2454
|
-
const authSession = await setupProxyAuth(config);
|
|
2455
|
-
try {
|
|
2456
|
-
await validateUpstream(config, appConfig.thinkEffort);
|
|
2457
|
-
} catch (error) {
|
|
2458
|
-
log.error("Startup preflight failed:", error);
|
|
2459
|
-
process.exit(1);
|
|
2460
|
-
}
|
|
2461
|
-
const server = startServer(config);
|
|
2462
|
-
log.info(`copilot-relay version: ${appVersion}`);
|
|
2463
|
-
if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
|
|
2464
|
-
else log.error("GitHub user: unavailable");
|
|
2465
|
-
log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
|
|
2466
|
-
log.info(`copilot base url: ${config.copilotBaseUrl}`);
|
|
2467
|
-
const baseUrl = `http://${config.host}:${config.port}`;
|
|
2468
|
-
if (appConfig.claudeSetup) try {
|
|
2469
|
-
const claudeResult = await applyClaudeConfig({
|
|
2470
|
-
baseUrl,
|
|
2471
|
-
configPath: claudeConfigPath$1
|
|
2472
|
-
});
|
|
2473
|
-
if (claudeResult.changed) {
|
|
2474
|
-
log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
|
|
2475
|
-
if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) log.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
|
|
2476
|
-
} else log.info(`claude settings already up to date: ${claudeResult.configPath}`);
|
|
2477
|
-
} catch (error) {
|
|
2478
|
-
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
2479
|
-
}
|
|
2480
|
-
watchAppConfig((nextConfig) => {
|
|
2481
|
-
applyRuntimeConfig(nextConfig);
|
|
2482
|
-
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort}`);
|
|
2483
|
-
});
|
|
2484
|
-
await new Promise((resolve$1, reject) => {
|
|
2485
|
-
server.on("close", resolve$1);
|
|
2486
|
-
server.on("error", reject);
|
|
2487
|
-
});
|
|
2882
|
+
const stopped = await stopExistingRelay(readProxyConfig(appConfig));
|
|
2883
|
+
if (stopped.length > 0) log.info(`Stopped copilot-relay pid(s): ${stopped.join(", ")}`);
|
|
2884
|
+
log.info("Starting copilot-relay");
|
|
2885
|
+
await startRelay(appConfig);
|
|
2488
2886
|
}
|
|
2489
2887
|
});
|
|
2490
2888
|
|
|
@@ -2497,6 +2895,7 @@ await runMain(defineCommand({
|
|
|
2497
2895
|
},
|
|
2498
2896
|
subCommands: {
|
|
2499
2897
|
auth,
|
|
2898
|
+
restart,
|
|
2500
2899
|
start
|
|
2501
2900
|
}
|
|
2502
2901
|
}));
|