copilot-relay 0.0.8 → 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 +3 -1
- package/dist/main.js +365 -92
- 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) => {
|
|
@@ -627,6 +630,179 @@ const auth = defineCommand({
|
|
|
627
630
|
}
|
|
628
631
|
});
|
|
629
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
|
+
|
|
630
806
|
//#endregion
|
|
631
807
|
//#region src/lib/claude-settings.ts
|
|
632
808
|
/**
|
|
@@ -735,7 +911,7 @@ const readCopilotText = async (response, signal, timeoutMs = copilotRequestTimeo
|
|
|
735
911
|
throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
|
|
736
912
|
}
|
|
737
913
|
};
|
|
738
|
-
const buildHeaders = (provider, init, options) => {
|
|
914
|
+
const buildHeaders = (provider, init, options, upstreamRequestId) => {
|
|
739
915
|
const headers = new Headers(init.headers);
|
|
740
916
|
headers.set("authorization", `Bearer ${provider.token}`);
|
|
741
917
|
headers.set("copilot-integration-id", "vscode-chat");
|
|
@@ -744,7 +920,7 @@ const buildHeaders = (provider, init, options) => {
|
|
|
744
920
|
headers.set("user-agent", userAgent);
|
|
745
921
|
headers.set("openai-intent", "conversation-panel");
|
|
746
922
|
headers.set("x-github-api-version", apiVersion);
|
|
747
|
-
headers.set("x-request-id",
|
|
923
|
+
headers.set("x-request-id", upstreamRequestId);
|
|
748
924
|
headers.set("x-vscode-user-agent-library-version", "electron-fetch");
|
|
749
925
|
if (options.vision) headers.set("copilot-vision-request", "true");
|
|
750
926
|
if (options.initiator) headers.set("x-initiator", options.initiator);
|
|
@@ -752,28 +928,48 @@ const buildHeaders = (provider, init, options) => {
|
|
|
752
928
|
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
753
929
|
return headers;
|
|
754
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
|
+
};
|
|
755
939
|
const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
756
940
|
if (!provider.token) throw new ProxyNotImplementedError("Copilot token is not configured for copilot-relay.");
|
|
757
941
|
const timeoutMs = options.timeoutMs ?? copilotRequestTimeoutMs;
|
|
758
942
|
const signal = createCopilotRequestSignal(options.signal, timeoutMs);
|
|
759
943
|
let lastError;
|
|
760
|
-
for (let attempt = 1; attempt <= maxFetchAttempts; attempt++)
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
+
}
|
|
777
973
|
}
|
|
778
974
|
throw lastError;
|
|
779
975
|
};
|
|
@@ -1228,6 +1424,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1228
1424
|
"Model request",
|
|
1229
1425
|
`client=${client}`,
|
|
1230
1426
|
`requested_model=${requestedModel}`,
|
|
1427
|
+
...options.requestId ? [`request_id=${options.requestId}`] : [],
|
|
1231
1428
|
`upstream_model=${compatiblePayload.model}`,
|
|
1232
1429
|
`requested_think_effort=${requestedThinkEffort}`,
|
|
1233
1430
|
`requested_thinking=${requestedThinking}`,
|
|
@@ -1237,6 +1434,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1237
1434
|
if (useResponsesApi) return createResponses(provider, compatiblePayload, {
|
|
1238
1435
|
vision: enableVision,
|
|
1239
1436
|
initiator,
|
|
1437
|
+
requestId: options.requestId,
|
|
1240
1438
|
signal,
|
|
1241
1439
|
timeoutMs: options.timeoutMs
|
|
1242
1440
|
});
|
|
@@ -1250,6 +1448,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1250
1448
|
}, {
|
|
1251
1449
|
vision: enableVision,
|
|
1252
1450
|
initiator,
|
|
1451
|
+
requestId: options.requestId,
|
|
1253
1452
|
signal,
|
|
1254
1453
|
timeoutMs: options.timeoutMs
|
|
1255
1454
|
});
|
|
@@ -1257,13 +1456,15 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1257
1456
|
if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
|
|
1258
1457
|
vision: enableVision,
|
|
1259
1458
|
initiator,
|
|
1459
|
+
requestId: options.requestId,
|
|
1260
1460
|
signal,
|
|
1261
1461
|
timeoutMs: options.timeoutMs
|
|
1262
1462
|
});
|
|
1263
1463
|
throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
|
|
1264
1464
|
model: payload.model,
|
|
1265
1465
|
request: requestPayload,
|
|
1266
|
-
route: "/chat/completions"
|
|
1466
|
+
route: "/chat/completions",
|
|
1467
|
+
requestId: options.requestId
|
|
1267
1468
|
}, signal, options.timeoutMs));
|
|
1268
1469
|
}
|
|
1269
1470
|
if (compatiblePayload.stream) return events(response);
|
|
@@ -1281,13 +1482,15 @@ async function createResponses(provider, payload, options) {
|
|
|
1281
1482
|
}, {
|
|
1282
1483
|
vision: options.vision,
|
|
1283
1484
|
initiator: options.initiator,
|
|
1485
|
+
requestId: options.requestId,
|
|
1284
1486
|
signal: options.signal,
|
|
1285
1487
|
timeoutMs: options.timeoutMs
|
|
1286
1488
|
});
|
|
1287
1489
|
if (!response.ok) throw new HTTPError("Failed to create responses", response, await logUpstreamError("Failed to create responses", response, {
|
|
1288
1490
|
model: payload.model,
|
|
1289
1491
|
request: requestPayload,
|
|
1290
|
-
route: "/responses"
|
|
1492
|
+
route: "/responses",
|
|
1493
|
+
requestId: options.requestId
|
|
1291
1494
|
}, options.signal, options.timeoutMs));
|
|
1292
1495
|
if (payload.stream) return translateResponsesStreamToChatCompletionStream(events(response));
|
|
1293
1496
|
return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
|
|
@@ -1295,10 +1498,11 @@ async function createResponses(provider, payload, options) {
|
|
|
1295
1498
|
async function logUpstreamError(message, response, context, signal, timeoutMs) {
|
|
1296
1499
|
const errorBody = await readCopilotText(response.clone(), signal, timeoutMs).catch(() => "");
|
|
1297
1500
|
const detail = getUpstreamErrorDetail(response, errorBody);
|
|
1298
|
-
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}` : ""}`, {
|
|
1299
1502
|
message,
|
|
1300
1503
|
route: context.route,
|
|
1301
1504
|
model: context.model,
|
|
1505
|
+
requestId: context.requestId,
|
|
1302
1506
|
request: context.request,
|
|
1303
1507
|
response: {
|
|
1304
1508
|
status: response.status,
|
|
@@ -1993,6 +2197,7 @@ const createClaudeWebSearchExecution = async (config, payload, requestedQuery, o
|
|
|
1993
2197
|
body: JSON.stringify(buildWebSearchRequestPayload(payload, requestedQuery, backendModel))
|
|
1994
2198
|
}, {
|
|
1995
2199
|
initiator: "agent",
|
|
2200
|
+
requestId: options.requestId,
|
|
1996
2201
|
signal,
|
|
1997
2202
|
timeoutMs: options.timeoutMs
|
|
1998
2203
|
});
|
|
@@ -2347,6 +2552,7 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
2347
2552
|
}));
|
|
2348
2553
|
claudeRoutes.post("/messages", async (c) => {
|
|
2349
2554
|
const config = c.get("config");
|
|
2555
|
+
const requestId = c.get("requestId");
|
|
2350
2556
|
const claudePayload = await c.req.json();
|
|
2351
2557
|
const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
2352
2558
|
log.debug("Full Claude request payload", { payload: claudePayload });
|
|
@@ -2362,6 +2568,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
2362
2568
|
requestedModel: claudePayload.model,
|
|
2363
2569
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
2364
2570
|
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
2571
|
+
requestId,
|
|
2365
2572
|
signal: requestSignal,
|
|
2366
2573
|
timeoutMs: config.upstreamTimeoutMs
|
|
2367
2574
|
});
|
|
@@ -2370,6 +2577,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
2370
2577
|
let claudeResponse;
|
|
2371
2578
|
if (webSearchToolCall) {
|
|
2372
2579
|
const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
|
|
2580
|
+
requestId,
|
|
2373
2581
|
signal: requestSignal,
|
|
2374
2582
|
timeoutMs: config.upstreamTimeoutMs
|
|
2375
2583
|
});
|
|
@@ -2381,6 +2589,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
2381
2589
|
requestedModel: claudePayload.model,
|
|
2382
2590
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
2383
2591
|
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
2592
|
+
requestId,
|
|
2384
2593
|
signal: requestSignal,
|
|
2385
2594
|
timeoutMs: config.upstreamTimeoutMs
|
|
2386
2595
|
});
|
|
@@ -2498,25 +2707,35 @@ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.fl
|
|
|
2498
2707
|
const value = request.headers.get(name);
|
|
2499
2708
|
return value ? [[name, value]] : [];
|
|
2500
2709
|
}));
|
|
2501
|
-
const formatStatusLog = (method, path$1, status, ms, errorMessage) => {
|
|
2502
|
-
const base =
|
|
2710
|
+
const formatStatusLog = (requestId, method, path$1, status, ms, errorMessage) => {
|
|
2711
|
+
const base = `request_id=${requestId} ${method} ${path$1} -> ${status} ${ms}ms`;
|
|
2503
2712
|
return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
|
|
2504
2713
|
};
|
|
2505
2714
|
const createServer = (config) => {
|
|
2506
2715
|
const app = new Hono();
|
|
2507
|
-
app.use("*", cors());
|
|
2508
2716
|
app.use("*", async (c, next) => {
|
|
2717
|
+
const requestId = randomUUID();
|
|
2509
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}`);
|
|
2510
2722
|
const started = performance.now();
|
|
2511
2723
|
try {
|
|
2512
2724
|
await next();
|
|
2513
2725
|
} finally {
|
|
2514
2726
|
const ms = Math.round(performance.now() - started);
|
|
2515
|
-
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")));
|
|
2516
2728
|
}
|
|
2517
2729
|
});
|
|
2730
|
+
app.use("*", cors());
|
|
2518
2731
|
app.onError((error, c) => {
|
|
2519
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
|
+
});
|
|
2520
2739
|
return c.json({ error: { message: "Internal server error" } }, 500);
|
|
2521
2740
|
});
|
|
2522
2741
|
app.get("/", (c) => c.json({
|
|
@@ -2531,6 +2750,7 @@ const createServer = (config) => {
|
|
|
2531
2750
|
log.error("Unsupported Claude API request", {
|
|
2532
2751
|
method: c.req.method,
|
|
2533
2752
|
path: c.req.path,
|
|
2753
|
+
requestId: c.get("requestId"),
|
|
2534
2754
|
headers: getLoggedHeaders(c.req.raw),
|
|
2535
2755
|
payload: await readRequestPayloadForLog(c.req.raw)
|
|
2536
2756
|
});
|
|
@@ -2538,79 +2758,131 @@ const createServer = (config) => {
|
|
|
2538
2758
|
});
|
|
2539
2759
|
return app;
|
|
2540
2760
|
};
|
|
2541
|
-
const startServer = (config) =>
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
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);
|
|
2545
2778
|
});
|
|
2546
2779
|
|
|
2547
2780
|
//#endregion
|
|
2548
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
|
+
}
|
|
2549
2861
|
const start = defineCommand({
|
|
2550
2862
|
meta: {
|
|
2551
2863
|
name: "start",
|
|
2552
2864
|
description: "Start the copilot-relay HTTP server."
|
|
2553
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
|
+
},
|
|
2554
2878
|
async run() {
|
|
2555
2879
|
const appConfig = await readAppConfig();
|
|
2556
2880
|
setLogLevel(appConfig.logLevel);
|
|
2557
2881
|
await cleanupLogs(appConfig.logRetentionDays);
|
|
2558
|
-
const
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
cleanupLogs(nextConfig.logRetentionDays);
|
|
2563
|
-
runtimeState.debug = nextConfig.logLevel === "debug";
|
|
2564
|
-
runtimeState.thinkEffort = nextConfig.thinkEffort;
|
|
2565
|
-
config.copilotBaseUrl = nextConfig.copilotBaseUrl;
|
|
2566
|
-
config.host = nextConfig.host;
|
|
2567
|
-
config.port = nextConfig.port;
|
|
2568
|
-
config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
|
|
2569
|
-
config.webSearchBackend = nextConfig.webSearchBackend;
|
|
2570
|
-
runtimeState.modelRouting = {
|
|
2571
|
-
gptModel: nextConfig.gptModel,
|
|
2572
|
-
opusModel: nextConfig.opusModel
|
|
2573
|
-
};
|
|
2574
|
-
};
|
|
2575
|
-
applyRuntimeConfig(appConfig);
|
|
2576
|
-
log.info(`Log level: ${appConfig.logLevel}`);
|
|
2577
|
-
log.info(`Think effort: ${appConfig.thinkEffort}`);
|
|
2578
|
-
log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
|
|
2579
|
-
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
2580
|
-
const authSession = await setupProxyAuth(config);
|
|
2581
|
-
try {
|
|
2582
|
-
await validateUpstream(config, appConfig.thinkEffort);
|
|
2583
|
-
} catch (error) {
|
|
2584
|
-
log.error("Startup preflight failed:", error);
|
|
2585
|
-
process.exit(1);
|
|
2586
|
-
}
|
|
2587
|
-
const server = startServer(config);
|
|
2588
|
-
log.info(`copilot-relay version: ${appVersion}`);
|
|
2589
|
-
if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
|
|
2590
|
-
else log.error("GitHub user: unavailable");
|
|
2591
|
-
log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
|
|
2592
|
-
log.info(`copilot base url: ${config.copilotBaseUrl}`);
|
|
2593
|
-
const baseUrl = `http://${config.host}:${config.port}`;
|
|
2594
|
-
if (appConfig.claudeSetup) try {
|
|
2595
|
-
const claudeResult = await applyClaudeConfig({
|
|
2596
|
-
baseUrl,
|
|
2597
|
-
configPath: claudeConfigPath$1
|
|
2598
|
-
});
|
|
2599
|
-
if (claudeResult.changed) {
|
|
2600
|
-
log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
|
|
2601
|
-
if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) log.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
|
|
2602
|
-
} else log.info(`claude settings already up to date: ${claudeResult.configPath}`);
|
|
2603
|
-
} catch (error) {
|
|
2604
|
-
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
2605
|
-
}
|
|
2606
|
-
watchAppConfig((nextConfig) => {
|
|
2607
|
-
applyRuntimeConfig(nextConfig);
|
|
2608
|
-
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
|
|
2609
|
-
});
|
|
2610
|
-
await new Promise((resolve$1, reject) => {
|
|
2611
|
-
server.on("close", resolve$1);
|
|
2612
|
-
server.on("error", reject);
|
|
2613
|
-
});
|
|
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);
|
|
2614
2886
|
}
|
|
2615
2887
|
});
|
|
2616
2888
|
|
|
@@ -2623,6 +2895,7 @@ await runMain(defineCommand({
|
|
|
2623
2895
|
},
|
|
2624
2896
|
subCommands: {
|
|
2625
2897
|
auth,
|
|
2898
|
+
restart,
|
|
2626
2899
|
start
|
|
2627
2900
|
}
|
|
2628
2901
|
}));
|