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/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 { serve } from "@hono/node-server";
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", randomUUID());
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++) try {
761
- const started = performance.now();
762
- const response = await fetch(`${provider.baseUrl}${path$1}`, {
763
- ...init,
764
- headers: buildHeaders(provider, init, options),
765
- signal
766
- });
767
- const ms = Math.round(performance.now() - started);
768
- log.debug(`Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt})`);
769
- if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
770
- log.error(`Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts})`);
771
- } catch (error) {
772
- const abortError = toCopilotAbortHTTPError(error, signal, timeoutMs);
773
- if (abortError) throw abortError;
774
- lastError = error;
775
- if (attempt === maxFetchAttempts) throw error;
776
- log.error(`Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts})`, error);
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 = `${method} ${path$1} -> ${status} ${ms}ms`;
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) => serve({
2542
- fetch: createServer(config).fetch,
2543
- hostname: config.host,
2544
- port: config.port
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 claudeConfigPath$1 = claudeConfigPath;
2559
- const config = readProxyConfig(appConfig);
2560
- const applyRuntimeConfig = (nextConfig) => {
2561
- setLogLevel(nextConfig.logLevel);
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
  }));