copilot-relay 0.2.5 → 0.2.7

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
@@ -24,6 +24,11 @@ Public API:
24
24
  - `POST /v1/messages/count_tokens`
25
25
  - `GET /v1/models`
26
26
  - `GET /healthz`
27
+ - `GET|HEAD /api/hello`
28
+
29
+ `/api/hello` is a static reachability probe Claude Code sends on startup and
30
+ around real traffic. Like `/healthz` it never contacts Copilot, so it proves the
31
+ relay is listening and nothing more.
27
32
 
28
33
  Claude WebSearch is bridge-managed: when the model selects the WebSearch tool,
29
34
  the relay executes Copilot `/responses` with `web_search_preview`, then sends the
@@ -135,8 +140,10 @@ check that proves the relay can actually serve Claude Code — a relay whose
135
140
  Copilot token expired an hour ago still answers `/healthz` and `/v1/models`,
136
141
  because neither contacts upstream. It is opt-in because it spends a few tokens.
137
142
 
138
- `--json` emits machine-readable output. Exit codes: `0` running, `1` not
139
- running, `2` running but the deep check failed.
143
+ `--json` emits machine-readable output. Exit codes: `0` running and reachable,
144
+ `1` not running, `2` running but not usable — the health probe failed, or
145
+ `--deep` was requested and failed. `0` requires a live process *and* a passing
146
+ health probe, so a relay that cannot answer `/healthz` never reports success.
140
147
 
141
148
  ## Logging
142
149
 
package/dist/main.js CHANGED
@@ -7,11 +7,11 @@ import os from "node:os";
7
7
  import path, { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { execFile } from "node:child_process";
10
+ import { createAdaptorServer } from "@hono/node-server";
10
11
  import { createHash, randomUUID } from "node:crypto";
11
12
  import { Agent, fetch as fetch$1 } from "undici";
12
13
  import { events } from "fetch-event-stream";
13
14
  import { readFileSync } from "node:fs";
14
- import { createAdaptorServer } from "@hono/node-server";
15
15
  import { Hono } from "hono";
16
16
  import { cors } from "hono/cors";
17
17
  import { streamSSE } from "hono/streaming";
@@ -864,6 +864,27 @@ const isRelayPid = async (pid) => {
864
864
  if (!command) return false;
865
865
  return isRelayStartProcess(command, await getProcessCwd(pid));
866
866
  };
867
+ /**
868
+ * The relay serving `config.port`, or undefined if nothing is.
869
+ *
870
+ * Deliberately narrower than findRelayProcessIds: it never consults the global
871
+ * process list. `stop` wants "any relay anywhere" so it can clean up strays;
872
+ * `status` is asked about one configured relay, and answering with a different
873
+ * one is worse than answering "not running" because it looks authoritative.
874
+ *
875
+ * Returns pid and address as a single coherent record, so a pid discovered one
876
+ * way can never be printed next to an address taken from another.
877
+ */
878
+ const findRelayOnPort = async (config) => {
879
+ const entry = await readRelayPidFileEntry();
880
+ if (entry && entry.port === config.port && isProcessAlive(entry.pid) && await isRelayPid(entry.pid)) return entry;
881
+ for (const pid of await getPortListenerPids(config.port)) if (await isRelayPid(pid)) return {
882
+ host: config.host,
883
+ pid,
884
+ port: config.port,
885
+ startedAt: ""
886
+ };
887
+ };
867
888
  const findRelayProcessIds = async (config) => {
868
889
  const candidates = /* @__PURE__ */ new Set();
869
890
  const storedPid = await readRelayPidFile();
@@ -1487,6 +1508,7 @@ function getFinishReason(response, hasFunctionCalls) {
1487
1508
  //#region src/copilot/chat.ts
1488
1509
  const usesMaxCompletionTokens = (modelId) => modelId.startsWith("gpt-5");
1489
1510
  const continuePrefillPrompt$1 = "Continue the assistant response from the previous assistant message.";
1511
+ const continueAfterNonUserPrompt = "Continue based on the context above.";
1490
1512
  const maxUserLength = 64;
1491
1513
  const sanitizeReasoningEffortForModel = (_modelId, reasoningEffort) => {
1492
1514
  return reasoningEffort ?? void 0;
@@ -1515,7 +1537,14 @@ const buildRequestPayload = (payload) => {
1515
1537
  };
1516
1538
  const normalizeFinalAssistantPrefill$1 = (payload) => {
1517
1539
  const lastMessage = payload.messages.at(-1);
1518
- if (lastMessage?.role !== "assistant") return payload;
1540
+ if (!lastMessage || lastMessage.role === "user") return payload;
1541
+ if (lastMessage.role !== "assistant") return {
1542
+ ...payload,
1543
+ messages: [...payload.messages, {
1544
+ role: "user",
1545
+ content: continueAfterNonUserPrompt
1546
+ }]
1547
+ };
1519
1548
  const messages = [...payload.messages];
1520
1549
  if (typeof lastMessage.content !== "string") {
1521
1550
  messages.pop();
@@ -2414,10 +2443,10 @@ const getWebSearchResultText = (search) => {
2414
2443
  ].join("\n");
2415
2444
  };
2416
2445
  const createWebSearchResultContextMessage = (search) => ({
2417
- role: "system",
2446
+ role: "user",
2418
2447
  content: [
2419
2448
  "Trusted bridge retrieval context: the assistant selected web_search, and copilot-relay executed it.",
2420
- "Use this context for the final answer. Do not describe it as user-provided or injected. Do not call web_search again.",
2449
+ "Use this context to complete the request. Do not describe it as user-provided or injected.",
2421
2450
  "If the user requested a specific output format, answer using only matching information from this context.",
2422
2451
  "If the user asked for a URL only, output only that URL with no surrounding text.",
2423
2452
  "",
@@ -2426,24 +2455,26 @@ const createWebSearchResultContextMessage = (search) => ({
2426
2455
  getWebSearchResultText(search)
2427
2456
  ].join("\n")
2428
2457
  });
2429
- const createFinalWebSearchPayload = (payload, search) => {
2430
- const messages = payload.messages.flatMap((message) => {
2431
- if (message.role === "tool") return [{
2432
- role: "developer",
2433
- content: ["Prior local tool result from the conversation:", String(message.content ?? "")].join("\n")
2434
- }];
2435
- if (message.tool_calls && message.tool_calls.length > 0) return message.content ? [{
2436
- ...message,
2437
- tool_calls: void 0
2438
- }] : [];
2439
- return [message];
2440
- });
2458
+ const isWebSearchTool = (tool, toolNameMapper) => isClaudeWebSearchToolName(toolNameMapper.toClaude(tool.function.name));
2459
+ const removeWebSearchTool = (tools, toolNameMapper) => {
2460
+ if (!tools || tools.length === 0) return;
2461
+ const remaining = tools.filter((tool) => !isWebSearchTool(tool, toolNameMapper));
2462
+ return remaining.length > 0 ? remaining : void 0;
2463
+ };
2464
+ const normalizeFinalToolChoice = (toolChoice, tools, toolNameMapper) => {
2465
+ if (!tools || tools.length === 0) return;
2466
+ if (toolChoice === "required") return "auto";
2467
+ if (toolChoice && typeof toolChoice === "object" && isClaudeWebSearchToolName(toolNameMapper.toClaude(toolChoice.function.name))) return "auto";
2468
+ return toolChoice;
2469
+ };
2470
+ const createFinalWebSearchPayload = (payload, search, toolNameMapper) => {
2471
+ const tools = removeWebSearchTool(payload.tools, toolNameMapper);
2441
2472
  return {
2442
2473
  ...payload,
2443
2474
  stream: false,
2444
- tools: void 0,
2445
- tool_choice: void 0,
2446
- messages: [...messages, createWebSearchResultContextMessage(search)]
2475
+ tools,
2476
+ tool_choice: normalizeFinalToolChoice(payload.tool_choice, tools, toolNameMapper),
2477
+ messages: [...payload.messages, createWebSearchResultContextMessage(search)]
2447
2478
  };
2448
2479
  };
2449
2480
  const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
@@ -2723,7 +2754,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
2723
2754
  const searchResponse = createClaudeWebSearchResponse(search);
2724
2755
  if (search.results.length === 0) claudeResponse = searchResponse;
2725
2756
  else {
2726
- const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2757
+ const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search, toolNameMapper), {
2727
2758
  client: "claude",
2728
2759
  requestedModel: claudePayload.model,
2729
2760
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
@@ -2903,6 +2934,7 @@ const createServer = (config) => {
2903
2934
  status: "ok"
2904
2935
  }));
2905
2936
  app.get("/healthz", (c) => c.json({ ok: true }));
2937
+ app.on(["GET", "HEAD"], "/api/hello", (c) => c.body(null, 200));
2906
2938
  app.route("/v1", claudeRoutes);
2907
2939
  app.notFound(async (c) => {
2908
2940
  const message = "Unsupported Claude API route";
@@ -2939,6 +2971,7 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
2939
2971
 
2940
2972
  //#endregion
2941
2973
  //#region src/start.ts
2974
+ const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
2942
2975
  async function startRelay(appConfig) {
2943
2976
  appConfig ??= await readAppConfig();
2944
2977
  setLogLevel(appConfig.logLevel);
@@ -2997,6 +3030,7 @@ async function startRelay(appConfig) {
2997
3030
  applyRuntimeConfig(nextConfig);
2998
3031
  log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
2999
3032
  });
3033
+ const shutdownGraceMs = 2e3;
3000
3034
  const shutdown = (signal) => {
3001
3035
  log.info(`Received ${signal}; shutting down copilot-relay`);
3002
3036
  server.close((error) => {
@@ -3005,6 +3039,13 @@ async function startRelay(appConfig) {
3005
3039
  process.exitCode = 1;
3006
3040
  }
3007
3041
  });
3042
+ if (canCloseConnections(server)) {
3043
+ server.closeIdleConnections();
3044
+ setTimeout(() => {
3045
+ log.info("Grace period elapsed; closing remaining connections");
3046
+ server.closeAllConnections();
3047
+ }, shutdownGraceMs).unref();
3048
+ }
3008
3049
  };
3009
3050
  process.once("SIGINT", shutdown);
3010
3051
  process.once("SIGTERM", shutdown);
@@ -3054,14 +3095,26 @@ const restart = defineCommand({
3054
3095
  *
3055
3096
  * 2 is distinct from 1 on purpose: "running but broken" and "not running" call
3056
3097
  * for different responses - restart the service versus re-authenticate.
3098
+ *
3099
+ * A failed health probe is a 2, not a 0. Printing FAILED while exiting 0 would
3100
+ * make every scripted caller treat an unreachable relay as fine. See #34.
3057
3101
  */
3058
3102
  const exitCodes = {
3059
- deepCheckFailed: 2,
3060
3103
  notRunning: 1,
3104
+ notUsable: 2,
3061
3105
  running: 0
3062
3106
  };
3063
3107
  const localProbeTimeoutMs = 5e3;
3064
3108
  const deepProbeTimeoutMs = 6e4;
3109
+ /**
3110
+ * Status to exit code. Pure, because this mapping is the contract every
3111
+ * scripted caller depends on and deserves direct coverage.
3112
+ *
3113
+ * 0 requires a live process *and* a passing health probe. Printing FAILED while
3114
+ * exiting 0 would make every scripted caller treat an unreachable relay as
3115
+ * fine. See #34.
3116
+ */
3117
+ const resolveExitCode = (status$1) => !status$1.running ? exitCodes.notRunning : !status$1.health?.ok ? exitCodes.notUsable : status$1.deep && !status$1.deep.ok ? exitCodes.notUsable : exitCodes.running;
3065
3118
  const formatUptime = (startedAt) => {
3066
3119
  if (!startedAt) return "unknown";
3067
3120
  const started = Date.parse(startedAt);
@@ -3189,15 +3242,12 @@ const collectStatus = async (options) => {
3189
3242
  thinkEffort: appConfig.thinkEffort,
3190
3243
  version: appVersion
3191
3244
  };
3192
- const pids = await findRelayProcessIds(readProxyConfig(appConfig));
3193
- if (pids.length === 0) return {
3245
+ const relay = await findRelayOnPort(readProxyConfig(appConfig));
3246
+ if (!relay) return {
3194
3247
  ...base,
3195
3248
  running: false
3196
3249
  };
3197
- const entry = await readRelayPidFileEntry();
3198
- const host = entry?.host ?? appConfig.host;
3199
- const port = entry?.port ?? appConfig.port;
3200
- const baseUrl = `http://${host}:${port}`;
3250
+ const baseUrl = `http://${relay.host}:${relay.port}`;
3201
3251
  const health = await checkHealth(baseUrl);
3202
3252
  const models = health.ok ? await readModels(baseUrl) : [];
3203
3253
  const deep = options.deep && health.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
@@ -3207,10 +3257,10 @@ const collectStatus = async (options) => {
3207
3257
  ...deep ? { deep } : {},
3208
3258
  health,
3209
3259
  models,
3210
- pid: entry?.pid ?? pids[0],
3211
- port,
3260
+ pid: relay.pid,
3261
+ port: relay.port,
3212
3262
  running: true,
3213
- ...entry?.startedAt ? { startedAt: entry.startedAt } : {}
3263
+ ...relay.startedAt ? { startedAt: relay.startedAt } : {}
3214
3264
  };
3215
3265
  };
3216
3266
  const status = defineCommand({
@@ -3232,7 +3282,7 @@ const status = defineCommand({
3232
3282
  const result = await collectStatus({ deep: Boolean(args.deep) });
3233
3283
  if (args.json) console.log(JSON.stringify(result, null, 2));
3234
3284
  else console.log(renderStatus(result).join("\n"));
3235
- process.exitCode = !result.running ? exitCodes.notRunning : result.deep && !result.deep.ok ? exitCodes.deepCheckFailed : exitCodes.running;
3285
+ process.exitCode = resolveExitCode(result);
3236
3286
  }
3237
3287
  });
3238
3288