copilot-relay 0.0.8 → 0.1.0

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,17 @@
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";
11
+ import { Agent, fetch as fetch$1 } from "undici";
10
12
  import { events } from "fetch-event-stream";
11
13
  import { readFileSync } from "node:fs";
12
- import { serve } from "@hono/node-server";
14
+ import { createAdaptorServer } from "@hono/node-server";
13
15
  import { Hono } from "hono";
14
16
  import { cors } from "hono/cors";
15
17
  import { streamSSE } from "hono/streaming";
@@ -56,6 +58,7 @@ const copilotTokenPath = path.join(appDir, "copilot_token.json");
56
58
  const githubTokenPath = path.join(appDir, "github_token");
57
59
  const logsDir = path.join(appDir, "logs");
58
60
  const logPath = path.join(logsDir, "copilot-relay.log");
61
+ const pidPath = path.join(appDir, "copilot-relay.pid");
59
62
  const paths = {
60
63
  appDir,
61
64
  configPath,
@@ -68,7 +71,8 @@ const paths = {
68
71
  ],
69
72
  legacyCopilotTokenPaths,
70
73
  logPath,
71
- logsDir
74
+ logsDir,
75
+ pidPath
72
76
  };
73
77
  async function ensurePaths() {
74
78
  await fs.mkdir(paths.appDir, { recursive: true });
@@ -167,7 +171,7 @@ const standardHeaders = () => ({
167
171
  accept: "application/json",
168
172
  "content-type": "application/json"
169
173
  });
170
- const sleep = (ms) => new Promise((resolve$1) => {
174
+ const sleep$1 = (ms) => new Promise((resolve$1) => {
171
175
  setTimeout(resolve$1, ms);
172
176
  });
173
177
  const readGitHubToken = async () => {
@@ -245,12 +249,12 @@ const pollAccessToken = async (deviceCode) => {
245
249
  })
246
250
  });
247
251
  if (!response.ok) {
248
- await sleep(sleepDuration);
252
+ await sleep$1(sleepDuration);
249
253
  continue;
250
254
  }
251
255
  const json = await response.json();
252
256
  if (json.access_token) return json.access_token;
253
- await sleep(sleepDuration);
257
+ await sleep$1(sleepDuration);
254
258
  }
255
259
  };
256
260
  const getGitHubUser = async (githubToken, vsCodeVersion) => {
@@ -627,6 +631,179 @@ const auth = defineCommand({
627
631
  }
628
632
  });
629
633
 
634
+ //#endregion
635
+ //#region src/lib/lifecycle.ts
636
+ const execFileAsync = promisify(execFile);
637
+ const stopTimeoutMs = 5e3;
638
+ const stopPollMs = 100;
639
+ const isNodeErrno = (error) => typeof error === "object" && error !== null && "code" in error;
640
+ const sleep = (ms) => new Promise((resolve$1) => {
641
+ setTimeout(resolve$1, ms);
642
+ });
643
+ const parsePid = (value) => {
644
+ const pid = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value.trim(), 10) : NaN;
645
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
646
+ };
647
+ const readCommandOutput = async (file, args) => {
648
+ try {
649
+ const { stdout } = await execFileAsync(file, args, {
650
+ maxBuffer: 1024 * 1024,
651
+ windowsHide: true
652
+ });
653
+ return String(stdout);
654
+ } catch (error) {
655
+ if (typeof error === "object" && error !== null && "stdout" in error && (typeof error.stdout === "string" || Buffer.isBuffer(error.stdout))) return String(error.stdout);
656
+ return "";
657
+ }
658
+ };
659
+ const parsePidLines = (output) => output.split(/\r?\n/).map((line) => parsePid(line)).filter((pid) => pid !== void 0);
660
+ const isRelayStartProcess = (command, cwd) => {
661
+ const normalizedCommand = command.replaceAll("\\", "/");
662
+ const normalizedCwd = cwd?.replaceAll("\\", "/");
663
+ if (!/(?:^|\s)start(?:\s|$)/.test(normalizedCommand)) return false;
664
+ if (normalizedCommand.includes("copilot-relay")) return true;
665
+ const usesNode = /(?:^|\s|\/)node(?:\.exe)?(?:\s|$)/i.test(normalizedCommand);
666
+ const usesDistMain = /(?:^|\s|\/)dist\/main\.js(?:\s|$)/.test(normalizedCommand);
667
+ return Boolean(usesNode && usesDistMain && normalizedCwd && normalizedCwd.split("/").some((part) => part.includes("copilot-relay")));
668
+ };
669
+ const isProcessAlive = (pid) => {
670
+ try {
671
+ process.kill(pid, 0);
672
+ return true;
673
+ } catch (error) {
674
+ return isNodeErrno(error) && error.code === "EPERM";
675
+ }
676
+ };
677
+ const readRelayPidFile = async () => {
678
+ try {
679
+ const trimmed = (await fs.readFile(paths.pidPath, "utf8")).trim();
680
+ if (!trimmed) return;
681
+ if (trimmed.startsWith("{")) return parsePid(JSON.parse(trimmed).pid);
682
+ return parsePid(trimmed);
683
+ } catch (error) {
684
+ if (isNodeErrno(error) && error.code === "ENOENT") return;
685
+ throw error;
686
+ }
687
+ };
688
+ const writeRelayPidFile = async (config) => {
689
+ await fs.mkdir(paths.appDir, { recursive: true });
690
+ const payload = {
691
+ host: config.host,
692
+ pid: process.pid,
693
+ port: config.port,
694
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
695
+ };
696
+ await fs.writeFile(paths.pidPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
697
+ };
698
+ const clearRelayPidFile = async (pid = process.pid, options = {}) => {
699
+ const currentPid = await readRelayPidFile();
700
+ if (!options.force && currentPid !== void 0 && currentPid !== pid) return;
701
+ await fs.unlink(paths.pidPath).catch((error) => {
702
+ if (!isNodeErrno(error) || error.code !== "ENOENT") throw error;
703
+ });
704
+ };
705
+ const getProcessCommand = async (pid) => {
706
+ if (process.platform === "win32") return (await readCommandOutput("powershell.exe", [
707
+ "-NoProfile",
708
+ "-Command",
709
+ `Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine`
710
+ ])).trim() || void 0;
711
+ return (await readCommandOutput("ps", [
712
+ "-p",
713
+ String(pid),
714
+ "-o",
715
+ "command="
716
+ ])).trim() || void 0;
717
+ };
718
+ const getProcessCwd = async (pid) => {
719
+ if (process.platform === "win32") return;
720
+ return (await readCommandOutput("lsof", [
721
+ "-a",
722
+ "-p",
723
+ String(pid),
724
+ "-d",
725
+ "cwd",
726
+ "-Fn"
727
+ ])).split(/\r?\n/).find((line) => line.startsWith("n"))?.slice(1);
728
+ };
729
+ const getPortListenerPids = async (port) => {
730
+ if (process.platform === "win32") return parsePidLines(await readCommandOutput("powershell.exe", [
731
+ "-NoProfile",
732
+ "-Command",
733
+ `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`
734
+ ]));
735
+ return parsePidLines(await readCommandOutput("lsof", [
736
+ "-nP",
737
+ `-iTCP:${port}`,
738
+ "-sTCP:LISTEN",
739
+ "-t"
740
+ ]));
741
+ };
742
+ const getProcessList = async () => {
743
+ return (process.platform === "win32" ? await readCommandOutput("powershell.exe", [
744
+ "-NoProfile",
745
+ "-Command",
746
+ "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"
747
+ ]) : await readCommandOutput("ps", ["-axo", "pid=,command="])).split(/\r?\n/).flatMap((line) => {
748
+ const match = process.platform === "win32" ? line.match(/^(\d+)\t(.+)$/) : line.match(/^\s*(\d+)\s+(.+)$/);
749
+ const pid = parsePid(match?.[1]);
750
+ const command = match?.[2]?.trim();
751
+ return pid && command ? [{
752
+ command,
753
+ pid
754
+ }] : [];
755
+ });
756
+ };
757
+ const isRelayPid = async (pid) => {
758
+ if (pid === process.pid || !isProcessAlive(pid)) return false;
759
+ const command = await getProcessCommand(pid);
760
+ if (!command) return false;
761
+ return isRelayStartProcess(command, await getProcessCwd(pid));
762
+ };
763
+ const findRelayProcessIds = async (config) => {
764
+ const candidates = /* @__PURE__ */ new Set();
765
+ const storedPid = await readRelayPidFile();
766
+ if (storedPid) candidates.add(storedPid);
767
+ for (const pid of await getPortListenerPids(config.port)) candidates.add(pid);
768
+ for (const { command, pid } of await getProcessList()) {
769
+ if (pid === process.pid) continue;
770
+ if (command.includes("copilot-relay") || command.includes("dist/main.js")) {
771
+ if (isRelayStartProcess(command, await getProcessCwd(pid))) candidates.add(pid);
772
+ }
773
+ }
774
+ const relayPids = [];
775
+ for (const pid of candidates) if (await isRelayPid(pid)) relayPids.push(pid);
776
+ return [...new Set(relayPids)].sort((left, right) => left - right);
777
+ };
778
+ const waitForExit = async (pid, timeoutMs) => {
779
+ const deadline = Date.now() + timeoutMs;
780
+ while (Date.now() < deadline) {
781
+ if (!isProcessAlive(pid)) return true;
782
+ await sleep(stopPollMs);
783
+ }
784
+ return !isProcessAlive(pid);
785
+ };
786
+ const stopProcess = async (pid) => {
787
+ if (!isProcessAlive(pid)) return;
788
+ log.info(`Stopping existing copilot-relay process pid=${pid}`);
789
+ process.kill(pid, "SIGTERM");
790
+ if (await waitForExit(pid, stopTimeoutMs)) return;
791
+ log.error(`copilot-relay process pid=${pid} did not stop; forcing termination`);
792
+ process.kill(pid, "SIGKILL");
793
+ if (!await waitForExit(pid, stopTimeoutMs)) throw new Error(`Could not stop copilot-relay process pid=${pid}`);
794
+ };
795
+ const stopExistingRelay = async (config) => {
796
+ const relayPids = await findRelayProcessIds(config);
797
+ if (relayPids.length === 0) {
798
+ log.info("No existing copilot-relay instance found");
799
+ await clearRelayPidFile(0, { force: true });
800
+ return [];
801
+ }
802
+ for (const pid of relayPids) await stopProcess(pid);
803
+ await clearRelayPidFile(0, { force: true });
804
+ return relayPids;
805
+ };
806
+
630
807
  //#endregion
631
808
  //#region src/lib/claude-settings.ts
632
809
  /**
@@ -692,6 +869,10 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
692
869
  const apiVersion = "2025-04-01";
693
870
  const maxFetchAttempts = 2;
694
871
  const copilotRequestTimeoutMs = 18e4;
872
+ const copilotDispatcher = new Agent({
873
+ allowH2: false,
874
+ connect: { allowH2: false }
875
+ });
695
876
  const getCopilotProviderContext = (config) => ({
696
877
  baseUrl: config.copilotBaseUrl,
697
878
  token: config.copilotToken,
@@ -735,7 +916,7 @@ const readCopilotText = async (response, signal, timeoutMs = copilotRequestTimeo
735
916
  throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
736
917
  }
737
918
  };
738
- const buildHeaders = (provider, init, options) => {
919
+ const buildHeaders = (provider, init, options, upstreamRequestId) => {
739
920
  const headers = new Headers(init.headers);
740
921
  headers.set("authorization", `Bearer ${provider.token}`);
741
922
  headers.set("copilot-integration-id", "vscode-chat");
@@ -744,36 +925,57 @@ const buildHeaders = (provider, init, options) => {
744
925
  headers.set("user-agent", userAgent);
745
926
  headers.set("openai-intent", "conversation-panel");
746
927
  headers.set("x-github-api-version", apiVersion);
747
- headers.set("x-request-id", randomUUID());
928
+ headers.set("x-request-id", upstreamRequestId);
748
929
  headers.set("x-vscode-user-agent-library-version", "electron-fetch");
749
930
  if (options.vision) headers.set("copilot-vision-request", "true");
750
931
  if (options.initiator) headers.set("x-initiator", options.initiator);
751
932
  if (!headers.has("content-type") && init.body !== void 0) headers.set("content-type", "application/json");
752
933
  if (!headers.has("accept")) headers.set("accept", "application/json");
753
- return headers;
934
+ return Object.fromEntries(headers.entries());
935
+ };
936
+ const formatRequestId = (requestId) => requestId ? `request_id=${requestId} ` : "";
937
+ const logUpstreamLifecycle = (requestId, message) => {
938
+ if (requestId) {
939
+ log.info(`${formatRequestId(requestId)}${message}`);
940
+ return;
941
+ }
942
+ log.debug(message);
754
943
  };
755
944
  const fetchCopilot = async (provider, path$1, init, options = {}) => {
756
945
  if (!provider.token) throw new ProxyNotImplementedError("Copilot token is not configured for copilot-relay.");
757
946
  const timeoutMs = options.timeoutMs ?? copilotRequestTimeoutMs;
758
947
  const signal = createCopilotRequestSignal(options.signal, timeoutMs);
759
948
  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);
949
+ for (let attempt = 1; attempt <= maxFetchAttempts; attempt++) {
950
+ const upstreamRequestId = randomUUID();
951
+ try {
952
+ const started = performance.now();
953
+ logUpstreamLifecycle(options.requestId, `send upstream method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
954
+ const response = await fetch$1(`${provider.baseUrl}${path$1}`, {
955
+ ...init,
956
+ headers: buildHeaders(provider, init, options, upstreamRequestId),
957
+ dispatcher: copilotDispatcher,
958
+ signal
959
+ });
960
+ const ms = Math.round(performance.now() - started);
961
+ logUpstreamLifecycle(options.requestId, `return from upstream method=${init.method ?? "GET"} path=${path$1} status=${response.status} ms=${ms} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
962
+ log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
963
+ if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
964
+ log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`);
965
+ } catch (error) {
966
+ const abortError = toCopilotAbortHTTPError(error, signal, timeoutMs);
967
+ if (abortError) {
968
+ logUpstreamLifecycle(options.requestId, `upstream failed method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
969
+ throw abortError;
970
+ }
971
+ logUpstreamLifecycle(options.requestId, `upstream failed method=${init.method ?? "GET"} path=${path$1} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
972
+ lastError = error;
973
+ if (attempt === maxFetchAttempts) {
974
+ log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed after ${attempt} attempts upstream_request_id=${upstreamRequestId}`, error);
975
+ throw error;
976
+ }
977
+ log.error(`${formatRequestId(options.requestId)}Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts}) upstream_request_id=${upstreamRequestId}`, error);
978
+ }
777
979
  }
778
980
  throw lastError;
779
981
  };
@@ -1228,6 +1430,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
1228
1430
  "Model request",
1229
1431
  `client=${client}`,
1230
1432
  `requested_model=${requestedModel}`,
1433
+ ...options.requestId ? [`request_id=${options.requestId}`] : [],
1231
1434
  `upstream_model=${compatiblePayload.model}`,
1232
1435
  `requested_think_effort=${requestedThinkEffort}`,
1233
1436
  `requested_thinking=${requestedThinking}`,
@@ -1237,6 +1440,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
1237
1440
  if (useResponsesApi) return createResponses(provider, compatiblePayload, {
1238
1441
  vision: enableVision,
1239
1442
  initiator,
1443
+ requestId: options.requestId,
1240
1444
  signal,
1241
1445
  timeoutMs: options.timeoutMs
1242
1446
  });
@@ -1250,6 +1454,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
1250
1454
  }, {
1251
1455
  vision: enableVision,
1252
1456
  initiator,
1457
+ requestId: options.requestId,
1253
1458
  signal,
1254
1459
  timeoutMs: options.timeoutMs
1255
1460
  });
@@ -1257,13 +1462,15 @@ const createChatCompletions = async (config, payload, options = {}) => {
1257
1462
  if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
1258
1463
  vision: enableVision,
1259
1464
  initiator,
1465
+ requestId: options.requestId,
1260
1466
  signal,
1261
1467
  timeoutMs: options.timeoutMs
1262
1468
  });
1263
1469
  throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
1264
1470
  model: payload.model,
1265
1471
  request: requestPayload,
1266
- route: "/chat/completions"
1472
+ route: "/chat/completions",
1473
+ requestId: options.requestId
1267
1474
  }, signal, options.timeoutMs));
1268
1475
  }
1269
1476
  if (compatiblePayload.stream) return events(response);
@@ -1281,13 +1488,15 @@ async function createResponses(provider, payload, options) {
1281
1488
  }, {
1282
1489
  vision: options.vision,
1283
1490
  initiator: options.initiator,
1491
+ requestId: options.requestId,
1284
1492
  signal: options.signal,
1285
1493
  timeoutMs: options.timeoutMs
1286
1494
  });
1287
1495
  if (!response.ok) throw new HTTPError("Failed to create responses", response, await logUpstreamError("Failed to create responses", response, {
1288
1496
  model: payload.model,
1289
1497
  request: requestPayload,
1290
- route: "/responses"
1498
+ route: "/responses",
1499
+ requestId: options.requestId
1291
1500
  }, options.signal, options.timeoutMs));
1292
1501
  if (payload.stream) return translateResponsesStreamToChatCompletionStream(events(response));
1293
1502
  return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
@@ -1295,10 +1504,11 @@ async function createResponses(provider, payload, options) {
1295
1504
  async function logUpstreamError(message, response, context, signal, timeoutMs) {
1296
1505
  const errorBody = await readCopilotText(response.clone(), signal, timeoutMs).catch(() => "");
1297
1506
  const detail = getUpstreamErrorDetail(response, errorBody);
1298
- log.error(`${message}: route=${context.route} model=${context.model} status=${response.status}`, {
1507
+ log.error(`${message}: route=${context.route} model=${context.model} status=${response.status}${context.requestId ? ` request_id=${context.requestId}` : ""}`, {
1299
1508
  message,
1300
1509
  route: context.route,
1301
1510
  model: context.model,
1511
+ requestId: context.requestId,
1302
1512
  request: context.request,
1303
1513
  response: {
1304
1514
  status: response.status,
@@ -1993,6 +2203,7 @@ const createClaudeWebSearchExecution = async (config, payload, requestedQuery, o
1993
2203
  body: JSON.stringify(buildWebSearchRequestPayload(payload, requestedQuery, backendModel))
1994
2204
  }, {
1995
2205
  initiator: "agent",
2206
+ requestId: options.requestId,
1996
2207
  signal,
1997
2208
  timeoutMs: options.timeoutMs
1998
2209
  });
@@ -2266,6 +2477,7 @@ const getTokenCount = async (payload, model) => {
2266
2477
  //#endregion
2267
2478
  //#region src/routes/claude.ts
2268
2479
  const claudeRoutes = new Hono();
2480
+ const streamKeepAliveIntervalMs = 15e3;
2269
2481
  const createTokenCountModel = (modelId) => ({
2270
2482
  capabilities: { tokenizer: "o200k_base" },
2271
2483
  id: modelId
@@ -2336,6 +2548,104 @@ const eventsFromClaudeResponse = (response) => {
2336
2548
  events$1.push({ type: "message_stop" });
2337
2549
  return events$1;
2338
2550
  };
2551
+ const createQueuedClaudeStreamWriter = (write) => {
2552
+ let pending = Promise.resolve();
2553
+ return (event) => {
2554
+ pending = pending.then(() => write(event));
2555
+ return pending;
2556
+ };
2557
+ };
2558
+ const startClaudeStreamKeepAlive = (writeEvent) => {
2559
+ const writePing = () => {
2560
+ writeEvent({ type: "ping" }).catch(() => void 0);
2561
+ };
2562
+ writePing();
2563
+ const timer = setInterval(writePing, streamKeepAliveIntervalMs);
2564
+ if (typeof timer.unref === "function") timer.unref();
2565
+ return () => {
2566
+ clearInterval(timer);
2567
+ };
2568
+ };
2569
+ const writeClaudeStreamEvents = async (events$1, writeEvent) => {
2570
+ for (const event of events$1) await writeEvent(event);
2571
+ };
2572
+ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent) => {
2573
+ const upstreamModel = translateModelName(claudePayload.model);
2574
+ const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
2575
+ const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2576
+ const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2577
+ const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2578
+ if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2579
+ const response = await createChatCompletions(config, openAIPayload, {
2580
+ client: "claude",
2581
+ requestedModel: claudePayload.model,
2582
+ requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2583
+ requestedThinking: getClaudeRequestedThinking(claudePayload),
2584
+ requestId,
2585
+ signal: requestSignal,
2586
+ timeoutMs: config.upstreamTimeoutMs
2587
+ });
2588
+ if (isNonStreamingResponse(response)) {
2589
+ const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2590
+ let claudeResponse;
2591
+ if (webSearchToolCall) {
2592
+ const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
2593
+ requestId,
2594
+ signal: requestSignal,
2595
+ timeoutMs: config.upstreamTimeoutMs
2596
+ });
2597
+ const searchResponse = createClaudeWebSearchResponse(search);
2598
+ if (search.results.length === 0) claudeResponse = searchResponse;
2599
+ else {
2600
+ const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2601
+ client: "claude",
2602
+ requestedModel: claudePayload.model,
2603
+ requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2604
+ requestedThinking: getClaudeRequestedThinking(claudePayload),
2605
+ requestId,
2606
+ signal: requestSignal,
2607
+ timeoutMs: config.upstreamTimeoutMs
2608
+ });
2609
+ if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2610
+ status: 502,
2611
+ headers: { "content-type": "text/plain" }
2612
+ }));
2613
+ claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2614
+ }
2615
+ } else claudeResponse = translateToClaude(response, toolNameMapper);
2616
+ if (writeEvent) {
2617
+ await writeClaudeStreamEvents(eventsFromClaudeResponse(claudeResponse), writeEvent);
2618
+ return;
2619
+ }
2620
+ return claudeResponse;
2621
+ }
2622
+ if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
2623
+ status: 502,
2624
+ headers: { "content-type": "text/plain" }
2625
+ }));
2626
+ if (!writeEvent) throw new HTTPError("Claude non-streaming request unexpectedly streamed", new Response("Claude non-streaming request unexpectedly streamed", {
2627
+ status: 502,
2628
+ headers: { "content-type": "text/plain" }
2629
+ }));
2630
+ const streamState = {
2631
+ messageStartSent: false,
2632
+ contentBlockIndex: 0,
2633
+ contentBlockOpen: false,
2634
+ thinkingBlockOpen: false,
2635
+ toolCalls: {}
2636
+ };
2637
+ for await (const rawEvent of response) {
2638
+ if (rawEvent.data === "[DONE]") break;
2639
+ if (!rawEvent.data) continue;
2640
+ let chunk;
2641
+ try {
2642
+ chunk = JSON.parse(rawEvent.data);
2643
+ } catch {
2644
+ continue;
2645
+ }
2646
+ await writeClaudeStreamEvents(translateChunkToClaudeEvents(chunk, streamState, toolNameMapper), writeEvent);
2647
+ }
2648
+ };
2339
2649
  claudeRoutes.get("/models", (c) => c.json({
2340
2650
  object: "list",
2341
2651
  data: getExposedModelIds().map((id) => ({
@@ -2347,94 +2657,29 @@ claudeRoutes.get("/models", (c) => c.json({
2347
2657
  }));
2348
2658
  claudeRoutes.post("/messages", async (c) => {
2349
2659
  const config = c.get("config");
2660
+ const requestId = c.get("requestId");
2350
2661
  const claudePayload = await c.req.json();
2351
2662
  const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
2352
2663
  log.debug("Full Claude request payload", { payload: claudePayload });
2353
- try {
2354
- const upstreamModel = translateModelName(claudePayload.model);
2355
- const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
2356
- const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2357
- const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2358
- const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2359
- if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2360
- const response = await createChatCompletions(config, openAIPayload, {
2361
- client: "claude",
2362
- requestedModel: claudePayload.model,
2363
- requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2364
- requestedThinking: getClaudeRequestedThinking(claudePayload),
2365
- signal: requestSignal,
2366
- timeoutMs: config.upstreamTimeoutMs
2367
- });
2368
- if (isNonStreamingResponse(response)) {
2369
- const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2370
- let claudeResponse;
2371
- if (webSearchToolCall) {
2372
- const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
2373
- signal: requestSignal,
2374
- timeoutMs: config.upstreamTimeoutMs
2375
- });
2376
- const searchResponse = createClaudeWebSearchResponse(search);
2377
- if (search.results.length === 0) claudeResponse = searchResponse;
2378
- else {
2379
- const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2380
- client: "claude",
2381
- requestedModel: claudePayload.model,
2382
- requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2383
- requestedThinking: getClaudeRequestedThinking(claudePayload),
2384
- signal: requestSignal,
2385
- timeoutMs: config.upstreamTimeoutMs
2386
- });
2387
- if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2388
- status: 502,
2389
- headers: { "content-type": "text/plain" }
2390
- }));
2391
- claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2392
- }
2393
- } else claudeResponse = translateToClaude(response, toolNameMapper);
2394
- if (!claudePayload.stream) return c.json(claudeResponse);
2395
- return streamSSE(c, async (stream) => {
2396
- for (const event of eventsFromClaudeResponse(claudeResponse)) await stream.writeSSE({
2397
- event: event.type,
2398
- data: JSON.stringify(event)
2399
- });
2400
- });
2401
- }
2402
- if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
2403
- status: 502,
2404
- headers: { "content-type": "text/plain" }
2664
+ if (claudePayload.stream) return streamSSE(c, async (stream) => {
2665
+ const streamStarted = performance.now();
2666
+ const writeEvent = createQueuedClaudeStreamWriter((event) => stream.writeSSE({
2667
+ event: event.type,
2668
+ data: JSON.stringify(event)
2405
2669
  }));
2406
- return streamSSE(c, async (stream) => {
2407
- const streamState = {
2408
- messageStartSent: false,
2409
- contentBlockIndex: 0,
2410
- contentBlockOpen: false,
2411
- thinkingBlockOpen: false,
2412
- toolCalls: {}
2413
- };
2414
- try {
2415
- for await (const rawEvent of response) {
2416
- if (rawEvent.data === "[DONE]") break;
2417
- if (!rawEvent.data) continue;
2418
- let chunk;
2419
- try {
2420
- chunk = JSON.parse(rawEvent.data);
2421
- } catch {
2422
- continue;
2423
- }
2424
- for (const event of translateChunkToClaudeEvents(chunk, streamState, toolNameMapper)) await stream.writeSSE({
2425
- event: event.type,
2426
- data: JSON.stringify(event)
2427
- });
2428
- }
2429
- } catch (error) {
2430
- log.error("Error during Claude stream translation:", error);
2431
- const errorEvent = translateErrorToClaudeErrorEvent();
2432
- await stream.writeSSE({
2433
- event: errorEvent.type,
2434
- data: JSON.stringify(errorEvent)
2435
- });
2436
- }
2437
- });
2670
+ const stopKeepAlive = startClaudeStreamKeepAlive(writeEvent);
2671
+ try {
2672
+ await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, writeEvent);
2673
+ } catch (error) {
2674
+ log.error("Error during Claude stream request:", error);
2675
+ await writeEvent(translateErrorToClaudeErrorEvent());
2676
+ } finally {
2677
+ stopKeepAlive();
2678
+ log.info(`request_id=${requestId} stream completed ${Math.round(performance.now() - streamStarted)}ms`);
2679
+ }
2680
+ });
2681
+ try {
2682
+ return c.json(await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId));
2438
2683
  } catch (error) {
2439
2684
  if (error instanceof ProxyNotImplementedError) {
2440
2685
  c.set("requestErrorMessage", error.message);
@@ -2498,25 +2743,35 @@ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.fl
2498
2743
  const value = request.headers.get(name);
2499
2744
  return value ? [[name, value]] : [];
2500
2745
  }));
2501
- const formatStatusLog = (method, path$1, status, ms, errorMessage) => {
2502
- const base = `${method} ${path$1} -> ${status} ${ms}ms`;
2746
+ const formatStatusLog = (requestId, method, path$1, status, ms, errorMessage) => {
2747
+ const base = `request_id=${requestId} ${method} ${path$1} -> ${status} ${ms}ms`;
2503
2748
  return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
2504
2749
  };
2505
2750
  const createServer = (config) => {
2506
2751
  const app = new Hono();
2507
- app.use("*", cors());
2508
2752
  app.use("*", async (c, next) => {
2753
+ const requestId = randomUUID();
2509
2754
  c.set("config", config);
2755
+ c.set("requestId", requestId);
2756
+ c.header("x-copilot-relay-request-id", requestId);
2757
+ log.info(`request_id=${requestId} request received method=${c.req.method} path=${c.req.path}`);
2510
2758
  const started = performance.now();
2511
2759
  try {
2512
2760
  await next();
2513
2761
  } finally {
2514
2762
  const ms = Math.round(performance.now() - started);
2515
- log.info(formatStatusLog(c.req.method, c.req.path, c.res.status, ms, c.get("requestErrorMessage")));
2763
+ log.info(formatStatusLog(requestId, c.req.method, c.req.path, c.res.status, ms, c.get("requestErrorMessage")));
2516
2764
  }
2517
2765
  });
2766
+ app.use("*", cors());
2518
2767
  app.onError((error, c) => {
2519
2768
  c.set("requestErrorMessage", error.message);
2769
+ log.error("Unhandled Claude API request error", {
2770
+ method: c.req.method,
2771
+ path: c.req.path,
2772
+ requestId: c.get("requestId"),
2773
+ error
2774
+ });
2520
2775
  return c.json({ error: { message: "Internal server error" } }, 500);
2521
2776
  });
2522
2777
  app.get("/", (c) => c.json({
@@ -2531,6 +2786,7 @@ const createServer = (config) => {
2531
2786
  log.error("Unsupported Claude API request", {
2532
2787
  method: c.req.method,
2533
2788
  path: c.req.path,
2789
+ requestId: c.get("requestId"),
2534
2790
  headers: getLoggedHeaders(c.req.raw),
2535
2791
  payload: await readRequestPayloadForLog(c.req.raw)
2536
2792
  });
@@ -2538,79 +2794,147 @@ const createServer = (config) => {
2538
2794
  });
2539
2795
  return app;
2540
2796
  };
2541
- const startServer = (config) => serve({
2542
- fetch: createServer(config).fetch,
2543
- hostname: config.host,
2544
- port: config.port
2797
+ const startServer = (config) => new Promise((resolve$1, reject) => {
2798
+ const server = createAdaptorServer({
2799
+ fetch: createServer(config).fetch,
2800
+ hostname: config.host,
2801
+ port: config.port
2802
+ });
2803
+ const onError = (error) => {
2804
+ server.off("listening", onListening);
2805
+ reject(error);
2806
+ };
2807
+ const onListening = () => {
2808
+ server.off("error", onError);
2809
+ resolve$1(server);
2810
+ };
2811
+ server.once("error", onError);
2812
+ server.once("listening", onListening);
2813
+ server.listen(config.port, config.host);
2545
2814
  });
2546
2815
 
2547
2816
  //#endregion
2548
2817
  //#region src/start.ts
2818
+ async function startRelay(appConfig) {
2819
+ appConfig ??= await readAppConfig();
2820
+ setLogLevel(appConfig.logLevel);
2821
+ await cleanupLogs(appConfig.logRetentionDays);
2822
+ const claudeConfigPath$1 = claudeConfigPath;
2823
+ const config = readProxyConfig(appConfig);
2824
+ const applyRuntimeConfig = (nextConfig) => {
2825
+ setLogLevel(nextConfig.logLevel);
2826
+ cleanupLogs(nextConfig.logRetentionDays);
2827
+ runtimeState.debug = nextConfig.logLevel === "debug";
2828
+ runtimeState.thinkEffort = nextConfig.thinkEffort;
2829
+ config.copilotBaseUrl = nextConfig.copilotBaseUrl;
2830
+ config.host = nextConfig.host;
2831
+ config.port = nextConfig.port;
2832
+ config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
2833
+ config.webSearchBackend = nextConfig.webSearchBackend;
2834
+ runtimeState.modelRouting = {
2835
+ gptModel: nextConfig.gptModel,
2836
+ opusModel: nextConfig.opusModel
2837
+ };
2838
+ };
2839
+ applyRuntimeConfig(appConfig);
2840
+ log.info(`Log level: ${appConfig.logLevel}`);
2841
+ log.info(`Think effort: ${appConfig.thinkEffort}`);
2842
+ log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
2843
+ log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
2844
+ const authSession = await setupProxyAuth(config);
2845
+ try {
2846
+ await validateUpstream(config, appConfig.thinkEffort);
2847
+ } catch (error) {
2848
+ log.error("Startup preflight failed:", error);
2849
+ process.exit(1);
2850
+ }
2851
+ const server = await startServer(config);
2852
+ await writeRelayPidFile(config);
2853
+ log.info(`copilot-relay version: ${appVersion}`);
2854
+ if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
2855
+ else log.error("GitHub user: unavailable");
2856
+ log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
2857
+ log.info(`copilot base url: ${config.copilotBaseUrl}`);
2858
+ const baseUrl = `http://${config.host}:${config.port}`;
2859
+ if (appConfig.claudeSetup) try {
2860
+ const claudeResult = await applyClaudeConfig({
2861
+ baseUrl,
2862
+ configPath: claudeConfigPath$1
2863
+ });
2864
+ if (claudeResult.changed) {
2865
+ log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
2866
+ if (claudeResult.previousBaseUrl && claudeResult.previousBaseUrl !== baseUrl) log.info(`ANTHROPIC_BASE_URL: ${claudeResult.previousBaseUrl} → ${baseUrl}`);
2867
+ } else log.info(`claude settings already up to date: ${claudeResult.configPath}`);
2868
+ } catch (error) {
2869
+ log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
2870
+ }
2871
+ watchAppConfig((nextConfig) => {
2872
+ applyRuntimeConfig(nextConfig);
2873
+ log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
2874
+ });
2875
+ const shutdown = (signal) => {
2876
+ log.info(`Received ${signal}; shutting down copilot-relay`);
2877
+ server.close((error) => {
2878
+ if (error) {
2879
+ log.error("Error while shutting down copilot-relay:", error);
2880
+ process.exitCode = 1;
2881
+ }
2882
+ });
2883
+ };
2884
+ process.once("SIGINT", shutdown);
2885
+ process.once("SIGTERM", shutdown);
2886
+ try {
2887
+ await new Promise((resolve$1, reject) => {
2888
+ server.on("close", resolve$1);
2889
+ server.on("error", reject);
2890
+ });
2891
+ } finally {
2892
+ process.off("SIGINT", shutdown);
2893
+ process.off("SIGTERM", shutdown);
2894
+ await clearRelayPidFile();
2895
+ }
2896
+ }
2549
2897
  const start = defineCommand({
2550
2898
  meta: {
2551
2899
  name: "start",
2552
2900
  description: "Start the copilot-relay HTTP server."
2553
2901
  },
2902
+ async run() {
2903
+ await startRelay();
2904
+ }
2905
+ });
2906
+
2907
+ //#endregion
2908
+ //#region src/restart.ts
2909
+ const restart = defineCommand({
2910
+ meta: {
2911
+ name: "restart",
2912
+ description: "Stop any existing copilot-relay instance and start a new one."
2913
+ },
2554
2914
  async run() {
2555
2915
  const appConfig = await readAppConfig();
2556
2916
  setLogLevel(appConfig.logLevel);
2557
2917
  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
- });
2918
+ const stopped = await stopExistingRelay(readProxyConfig(appConfig));
2919
+ if (stopped.length > 0) log.info(`Stopped copilot-relay pid(s): ${stopped.join(", ")}`);
2920
+ log.info("Starting copilot-relay");
2921
+ await startRelay(appConfig);
2922
+ }
2923
+ });
2924
+
2925
+ //#endregion
2926
+ //#region src/stop.ts
2927
+ const stop = defineCommand({
2928
+ meta: {
2929
+ name: "stop",
2930
+ description: "Stop all detected copilot-relay server instances."
2931
+ },
2932
+ async run() {
2933
+ const appConfig = await readAppConfig();
2934
+ setLogLevel(appConfig.logLevel);
2935
+ await cleanupLogs(appConfig.logRetentionDays);
2936
+ const stopped = await stopExistingRelay(readProxyConfig(appConfig));
2937
+ if (stopped.length > 0) log.info(`Stopped copilot-relay pid(s): ${stopped.join(", ")}`);
2614
2938
  }
2615
2939
  });
2616
2940
 
@@ -2623,7 +2947,9 @@ await runMain(defineCommand({
2623
2947
  },
2624
2948
  subCommands: {
2625
2949
  auth,
2626
- start
2950
+ restart,
2951
+ start,
2952
+ stop
2627
2953
  }
2628
2954
  }));
2629
2955