copilot-relay 0.0.9 → 0.1.1

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 CHANGED
@@ -45,6 +45,7 @@ Routing:
45
45
  npx copilot-relay@latest auth
46
46
  npx copilot-relay@latest start
47
47
  npx copilot-relay@latest restart
48
+ npx copilot-relay@latest stop
48
49
  ```
49
50
 
50
51
  `start` writes `ANTHROPIC_BASE_URL` and a dummy `ANTHROPIC_AUTH_TOKEN` into `~/.claude/settings.json`.
@@ -92,6 +93,7 @@ The same folder stores `copilot_token.json` for the cached Copilot bearer token,
92
93
  copilot-relay auth
93
94
  copilot-relay start
94
95
  copilot-relay restart
96
+ copilot-relay stop
95
97
  ```
96
98
 
97
99
  ## Logging
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import path, { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { execFile } from "node:child_process";
10
10
  import { createHash, randomUUID } from "node:crypto";
11
+ import { Agent, fetch as fetch$1 } from "undici";
11
12
  import { events } from "fetch-event-stream";
12
13
  import { readFileSync } from "node:fs";
13
14
  import { createAdaptorServer } from "@hono/node-server";
@@ -868,6 +869,10 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
868
869
  const apiVersion = "2025-04-01";
869
870
  const maxFetchAttempts = 2;
870
871
  const copilotRequestTimeoutMs = 18e4;
872
+ const copilotDispatcher = new Agent({
873
+ allowH2: false,
874
+ connect: { allowH2: false }
875
+ });
871
876
  const getCopilotProviderContext = (config) => ({
872
877
  baseUrl: config.copilotBaseUrl,
873
878
  token: config.copilotToken,
@@ -926,7 +931,7 @@ const buildHeaders = (provider, init, options, upstreamRequestId) => {
926
931
  if (options.initiator) headers.set("x-initiator", options.initiator);
927
932
  if (!headers.has("content-type") && init.body !== void 0) headers.set("content-type", "application/json");
928
933
  if (!headers.has("accept")) headers.set("accept", "application/json");
929
- return headers;
934
+ return Object.fromEntries(headers.entries());
930
935
  };
931
936
  const formatRequestId = (requestId) => requestId ? `request_id=${requestId} ` : "";
932
937
  const logUpstreamLifecycle = (requestId, message) => {
@@ -946,9 +951,10 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
946
951
  try {
947
952
  const started = performance.now();
948
953
  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}`, {
954
+ const response = await fetch$1(`${provider.baseUrl}${path$1}`, {
950
955
  ...init,
951
956
  headers: buildHeaders(provider, init, options, upstreamRequestId),
957
+ dispatcher: copilotDispatcher,
952
958
  signal
953
959
  });
954
960
  const ms = Math.round(performance.now() - started);
@@ -2541,6 +2547,93 @@ const eventsFromClaudeResponse = (response) => {
2541
2547
  events$1.push({ type: "message_stop" });
2542
2548
  return events$1;
2543
2549
  };
2550
+ const createQueuedClaudeStreamWriter = (write) => {
2551
+ let pending = Promise.resolve();
2552
+ return (event) => {
2553
+ pending = pending.then(() => write(event));
2554
+ return pending;
2555
+ };
2556
+ };
2557
+ const writeClaudeStreamEvents = async (events$1, writeEvent) => {
2558
+ for (const event of events$1) await writeEvent(event);
2559
+ };
2560
+ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent) => {
2561
+ const upstreamModel = translateModelName(claudePayload.model);
2562
+ const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
2563
+ const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2564
+ const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2565
+ const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2566
+ if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2567
+ const response = await createChatCompletions(config, openAIPayload, {
2568
+ client: "claude",
2569
+ requestedModel: claudePayload.model,
2570
+ requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2571
+ requestedThinking: getClaudeRequestedThinking(claudePayload),
2572
+ requestId,
2573
+ signal: requestSignal,
2574
+ timeoutMs: config.upstreamTimeoutMs
2575
+ });
2576
+ if (isNonStreamingResponse(response)) {
2577
+ const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2578
+ let claudeResponse;
2579
+ if (webSearchToolCall) {
2580
+ const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
2581
+ requestId,
2582
+ signal: requestSignal,
2583
+ timeoutMs: config.upstreamTimeoutMs
2584
+ });
2585
+ const searchResponse = createClaudeWebSearchResponse(search);
2586
+ if (search.results.length === 0) claudeResponse = searchResponse;
2587
+ else {
2588
+ const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2589
+ client: "claude",
2590
+ requestedModel: claudePayload.model,
2591
+ requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2592
+ requestedThinking: getClaudeRequestedThinking(claudePayload),
2593
+ requestId,
2594
+ signal: requestSignal,
2595
+ timeoutMs: config.upstreamTimeoutMs
2596
+ });
2597
+ if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2598
+ status: 502,
2599
+ headers: { "content-type": "text/plain" }
2600
+ }));
2601
+ claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2602
+ }
2603
+ } else claudeResponse = translateToClaude(response, toolNameMapper);
2604
+ if (writeEvent) {
2605
+ await writeClaudeStreamEvents(eventsFromClaudeResponse(claudeResponse), writeEvent);
2606
+ return;
2607
+ }
2608
+ return claudeResponse;
2609
+ }
2610
+ if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
2611
+ status: 502,
2612
+ headers: { "content-type": "text/plain" }
2613
+ }));
2614
+ if (!writeEvent) throw new HTTPError("Claude non-streaming request unexpectedly streamed", new Response("Claude non-streaming request unexpectedly streamed", {
2615
+ status: 502,
2616
+ headers: { "content-type": "text/plain" }
2617
+ }));
2618
+ const streamState = {
2619
+ messageStartSent: false,
2620
+ contentBlockIndex: 0,
2621
+ contentBlockOpen: false,
2622
+ thinkingBlockOpen: false,
2623
+ toolCalls: {}
2624
+ };
2625
+ for await (const rawEvent of response) {
2626
+ if (rawEvent.data === "[DONE]") break;
2627
+ if (!rawEvent.data) continue;
2628
+ let chunk;
2629
+ try {
2630
+ chunk = JSON.parse(rawEvent.data);
2631
+ } catch {
2632
+ continue;
2633
+ }
2634
+ await writeClaudeStreamEvents(translateChunkToClaudeEvents(chunk, streamState, toolNameMapper), writeEvent);
2635
+ }
2636
+ };
2544
2637
  claudeRoutes.get("/models", (c) => c.json({
2545
2638
  object: "list",
2546
2639
  data: getExposedModelIds().map((id) => ({
@@ -2556,94 +2649,23 @@ claudeRoutes.post("/messages", async (c) => {
2556
2649
  const claudePayload = await c.req.json();
2557
2650
  const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
2558
2651
  log.debug("Full Claude request payload", { payload: claudePayload });
2559
- try {
2560
- const upstreamModel = translateModelName(claudePayload.model);
2561
- const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
2562
- const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2563
- const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2564
- const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2565
- if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2566
- const response = await createChatCompletions(config, openAIPayload, {
2567
- client: "claude",
2568
- requestedModel: claudePayload.model,
2569
- requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2570
- requestedThinking: getClaudeRequestedThinking(claudePayload),
2571
- requestId,
2572
- signal: requestSignal,
2573
- timeoutMs: config.upstreamTimeoutMs
2574
- });
2575
- if (isNonStreamingResponse(response)) {
2576
- const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2577
- let claudeResponse;
2578
- if (webSearchToolCall) {
2579
- const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
2580
- requestId,
2581
- signal: requestSignal,
2582
- timeoutMs: config.upstreamTimeoutMs
2583
- });
2584
- const searchResponse = createClaudeWebSearchResponse(search);
2585
- if (search.results.length === 0) claudeResponse = searchResponse;
2586
- else {
2587
- const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2588
- client: "claude",
2589
- requestedModel: claudePayload.model,
2590
- requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2591
- requestedThinking: getClaudeRequestedThinking(claudePayload),
2592
- requestId,
2593
- signal: requestSignal,
2594
- timeoutMs: config.upstreamTimeoutMs
2595
- });
2596
- if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2597
- status: 502,
2598
- headers: { "content-type": "text/plain" }
2599
- }));
2600
- claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2601
- }
2602
- } else claudeResponse = translateToClaude(response, toolNameMapper);
2603
- if (!claudePayload.stream) return c.json(claudeResponse);
2604
- return streamSSE(c, async (stream) => {
2605
- for (const event of eventsFromClaudeResponse(claudeResponse)) await stream.writeSSE({
2606
- event: event.type,
2607
- data: JSON.stringify(event)
2608
- });
2609
- });
2610
- }
2611
- if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
2612
- status: 502,
2613
- headers: { "content-type": "text/plain" }
2652
+ if (claudePayload.stream) return streamSSE(c, async (stream) => {
2653
+ const streamStarted = performance.now();
2654
+ const writeEvent = createQueuedClaudeStreamWriter((event) => stream.writeSSE({
2655
+ event: event.type,
2656
+ data: JSON.stringify(event)
2614
2657
  }));
2615
- return streamSSE(c, async (stream) => {
2616
- const streamState = {
2617
- messageStartSent: false,
2618
- contentBlockIndex: 0,
2619
- contentBlockOpen: false,
2620
- thinkingBlockOpen: false,
2621
- toolCalls: {}
2622
- };
2623
- try {
2624
- for await (const rawEvent of response) {
2625
- if (rawEvent.data === "[DONE]") break;
2626
- if (!rawEvent.data) continue;
2627
- let chunk;
2628
- try {
2629
- chunk = JSON.parse(rawEvent.data);
2630
- } catch {
2631
- continue;
2632
- }
2633
- for (const event of translateChunkToClaudeEvents(chunk, streamState, toolNameMapper)) await stream.writeSSE({
2634
- event: event.type,
2635
- data: JSON.stringify(event)
2636
- });
2637
- }
2638
- } catch (error) {
2639
- log.error("Error during Claude stream translation:", error);
2640
- const errorEvent = translateErrorToClaudeErrorEvent();
2641
- await stream.writeSSE({
2642
- event: errorEvent.type,
2643
- data: JSON.stringify(errorEvent)
2644
- });
2645
- }
2646
- });
2658
+ try {
2659
+ await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, writeEvent);
2660
+ } catch (error) {
2661
+ log.error("Error during Claude stream request:", error);
2662
+ await writeEvent(translateErrorToClaudeErrorEvent());
2663
+ } finally {
2664
+ log.info(`request_id=${requestId} stream completed ${Math.round(performance.now() - streamStarted)}ms`);
2665
+ }
2666
+ });
2667
+ try {
2668
+ return c.json(await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId));
2647
2669
  } catch (error) {
2648
2670
  if (error instanceof ProxyNotImplementedError) {
2649
2671
  c.set("requestErrorMessage", error.message);
@@ -2886,6 +2908,22 @@ const restart = defineCommand({
2886
2908
  }
2887
2909
  });
2888
2910
 
2911
+ //#endregion
2912
+ //#region src/stop.ts
2913
+ const stop = defineCommand({
2914
+ meta: {
2915
+ name: "stop",
2916
+ description: "Stop all detected copilot-relay server instances."
2917
+ },
2918
+ async run() {
2919
+ const appConfig = await readAppConfig();
2920
+ setLogLevel(appConfig.logLevel);
2921
+ await cleanupLogs(appConfig.logRetentionDays);
2922
+ const stopped = await stopExistingRelay(readProxyConfig(appConfig));
2923
+ if (stopped.length > 0) log.info(`Stopped copilot-relay pid(s): ${stopped.join(", ")}`);
2924
+ }
2925
+ });
2926
+
2889
2927
  //#endregion
2890
2928
  //#region src/main.ts
2891
2929
  await runMain(defineCommand({
@@ -2896,7 +2934,8 @@ await runMain(defineCommand({
2896
2934
  subCommands: {
2897
2935
  auth,
2898
2936
  restart,
2899
- start
2937
+ start,
2938
+ stop
2900
2939
  }
2901
2940
  }));
2902
2941