copilot-relay 0.3.0 → 0.3.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
@@ -134,6 +134,7 @@ it is reachable:
134
134
  ```text
135
135
  copilot-relay 0.2.5
136
136
  process running (pid 93744, up 1h 16m)
137
+ version 0.2.5
137
138
  listening http://127.0.0.1:4142
138
139
  health ok (9ms)
139
140
  models gpt-5.6-sol[1m], claude-opus-5
@@ -142,6 +143,23 @@ copilot-relay 0.2.5
142
143
  config ~/.copilot-relay/config.yaml (logLevel=info, thinkEffort=max)
143
144
  ```
144
145
 
146
+ The first line is the CLI you invoked; the `version` row is the build the
147
+ running daemon reports about itself. They differ after an upgrade that has not
148
+ been restarted yet — the new CLI is installed, the old process is still
149
+ serving — and `status` says so rather than letting the header imply the upgrade
150
+ took effect:
151
+
152
+ ```text
153
+ version 0.2.6 — MISMATCH, 0.3.0 is installed
154
+ ...
155
+ The running relay is 0.2.6; 0.3.0 is installed.
156
+ Restart it to serve the installed version: copilot-relay restart
157
+ ```
158
+
159
+ A mismatch does not change the exit code: the relay works, it is just not the
160
+ build you installed. A daemon older than v0.3.1 does not report a version at
161
+ all, which shows as `unknown`.
162
+
145
163
  `--deep` additionally sends a real request through Copilot. That is the only
146
164
  check that proves the relay can actually serve Claude Code — a relay whose
147
165
  Copilot token expired an hour ago still answers `/healthz` and `/v1/models`,
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 { readFileSync } from "node:fs";
10
11
  import { createAdaptorServer } from "@hono/node-server";
11
12
  import { createHash, randomUUID } from "node:crypto";
12
13
  import { Agent, fetch as fetch$1 } from "undici";
13
14
  import { events } from "fetch-event-stream";
14
- import { readFileSync } from "node:fs";
15
15
  import { Hono } from "hono";
16
16
  import { cors } from "hono/cors";
17
17
  import { streamSSE } from "hono/streaming";
@@ -708,6 +708,24 @@ const auth = defineCommand({
708
708
  }
709
709
  });
710
710
 
711
+ //#endregion
712
+ //#region src/lib/version.ts
713
+ const packageName = "copilot-relay";
714
+ const readPackageVersion = () => {
715
+ let currentDir = dirname(fileURLToPath(import.meta.url));
716
+ while (true) {
717
+ try {
718
+ const packageJsonPath = resolve(currentDir, "package.json");
719
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
720
+ if (packageJson.name === packageName && typeof packageJson.version === "string") return packageJson.version;
721
+ } catch {}
722
+ const parentDir = dirname(currentDir);
723
+ if (parentDir === currentDir) return "unknown";
724
+ currentDir = parentDir;
725
+ }
726
+ };
727
+ const appVersion = readPackageVersion();
728
+
711
729
  //#endregion
712
730
  //#region src/lib/lifecycle.ts
713
731
  const execFileAsync = promisify(execFile);
@@ -772,7 +790,8 @@ const readRelayPidFileEntry = async () => {
772
790
  host: payload.host,
773
791
  pid,
774
792
  port: payload.port,
775
- startedAt: typeof payload.startedAt === "string" ? payload.startedAt : ""
793
+ startedAt: typeof payload.startedAt === "string" ? payload.startedAt : "",
794
+ ...typeof payload.version === "string" && payload.version ? { version: payload.version } : {}
776
795
  };
777
796
  } catch {
778
797
  return;
@@ -795,7 +814,8 @@ const writeRelayPidFile = async (config) => {
795
814
  host: config.host,
796
815
  pid: process.pid,
797
816
  port: config.port,
798
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
817
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
818
+ version: appVersion
799
819
  };
800
820
  await fs.writeFile(paths.pidPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
801
821
  };
@@ -1759,24 +1779,6 @@ const validateUpstream = async (config, thinkEffort) => {
1759
1779
  for (const model of getUpstreamModelIds()) await validateModelRequest(config, model, thinkEffort);
1760
1780
  };
1761
1781
 
1762
- //#endregion
1763
- //#region src/lib/version.ts
1764
- const packageName = "copilot-relay";
1765
- const readPackageVersion = () => {
1766
- let currentDir = dirname(fileURLToPath(import.meta.url));
1767
- while (true) {
1768
- try {
1769
- const packageJsonPath = resolve(currentDir, "package.json");
1770
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
1771
- if (packageJson.name === packageName && typeof packageJson.version === "string") return packageJson.version;
1772
- } catch {}
1773
- const parentDir = dirname(currentDir);
1774
- if (parentDir === currentDir) return "unknown";
1775
- currentDir = parentDir;
1776
- }
1777
- };
1778
- const appVersion = readPackageVersion();
1779
-
1780
1782
  //#endregion
1781
1783
  //#region src/claude/utils.ts
1782
1784
  function mapOpenAIStopReasonToClaude(finishReason) {
@@ -3142,7 +3144,10 @@ const createServer = (config) => {
3142
3144
  name: "copilot-relay",
3143
3145
  status: "ok"
3144
3146
  }));
3145
- app.get("/healthz", (c) => c.json({ ok: true }));
3147
+ app.get("/healthz", (c) => c.json({
3148
+ ok: true,
3149
+ version: appVersion
3150
+ }));
3146
3151
  app.on(["GET", "HEAD"], "/api/hello", (c) => c.body(null, 200));
3147
3152
  app.route("/v1", claudeRoutes);
3148
3153
  app.notFound(async (c) => {
@@ -3316,12 +3321,26 @@ const exitCodes = {
3316
3321
  const localProbeTimeoutMs = 5e3;
3317
3322
  const deepProbeTimeoutMs = 6e4;
3318
3323
  /**
3324
+ * True when the daemon is serving a different build than the invoked CLI.
3325
+ *
3326
+ * Pure and exported because it drives both the rendered warning and the
3327
+ * deliberate decision to leave the exit code at 0; both deserve direct
3328
+ * coverage. Undefined daemonVersion is not a mismatch - unknown is not
3329
+ * different, and claiming otherwise would warn on every pre-v0.3.1 daemon.
3330
+ */
3331
+ const hasVersionMismatch = (status$1) => status$1.running && status$1.daemonVersion !== void 0 && status$1.daemonVersion !== status$1.version;
3332
+ /**
3319
3333
  * Status to exit code. Pure, because this mapping is the contract every
3320
3334
  * scripted caller depends on and deserves direct coverage.
3321
3335
  *
3322
3336
  * 0 requires a live process *and* a passing health probe. Printing FAILED while
3323
3337
  * exiting 0 would make every scripted caller treat an unreachable relay as
3324
3338
  * fine. See #34.
3339
+ *
3340
+ * A version mismatch stays 0 on purpose (#43). The relay works; it is just not
3341
+ * the build that was installed. Mapping it to 2 would overstate it and break
3342
+ * every scripted caller that treats non-zero as broken. It is surfaced in the
3343
+ * text instead.
3325
3344
  */
3326
3345
  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;
3327
3346
  const formatUptime = (startedAt) => {
@@ -3344,7 +3363,9 @@ const renderStatus = (status$1) => {
3344
3363
  lines.push(" process not running", ` config ${status$1.configPath} (logLevel=${status$1.logLevel}, thinkEffort=${status$1.thinkEffort})`, ` log ${status$1.logPath}`, "", " Start it with: copilot-relay start");
3345
3364
  return lines;
3346
3365
  }
3347
- lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})`, ` listening ${status$1.baseUrl}`);
3366
+ lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})`);
3367
+ lines.push(status$1.daemonVersion === void 0 ? " version unknown (daemon predates version reporting — restart to report it)" : hasVersionMismatch(status$1) ? ` version ${status$1.daemonVersion} — MISMATCH, ${status$1.version} is installed` : ` version ${status$1.daemonVersion}`);
3368
+ lines.push(` listening ${status$1.baseUrl}`);
3348
3369
  const health = status$1.health;
3349
3370
  lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
3350
3371
  lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
@@ -3352,6 +3373,7 @@ const renderStatus = (status$1) => {
3352
3373
  else lines.push(" upstream not checked (use --deep)");
3353
3374
  lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath} (logLevel=${status$1.logLevel}, thinkEffort=${status$1.thinkEffort})`);
3354
3375
  if (status$1.health?.ok && status$1.deep && !status$1.deep.ok) lines.push("", " The relay is listening but cannot reach Copilot.", " Try: copilot-relay auth");
3376
+ if (hasVersionMismatch(status$1)) lines.push("", ` The running relay is ${status$1.daemonVersion}; ${status$1.version} is installed.`, " Restart it to serve the installed version: copilot-relay restart");
3355
3377
  return lines;
3356
3378
  };
3357
3379
  const probe = async (url, init, timeoutMs) => {
@@ -3368,22 +3390,34 @@ const probe = async (url, init, timeoutMs) => {
3368
3390
  };
3369
3391
  };
3370
3392
  const describeError = (error) => error instanceof Error ? error.name === "TimeoutError" ? "timed out" : error.message : String(error);
3393
+ /**
3394
+ * Probes /healthz, which also reports the daemon's own version.
3395
+ *
3396
+ * The version rides along on a probe `status` already makes, so asking what is
3397
+ * running costs nothing extra. Absent for a daemon older than v0.3.1, which is
3398
+ * itself informative, so it stays optional rather than defaulting to the
3399
+ * caller's version - that substitution is the bug. See #43.
3400
+ */
3371
3401
  const checkHealth = async (baseUrl) => {
3372
3402
  try {
3373
- const result = await probe(`${baseUrl}/healthz`, {}, localProbeTimeoutMs);
3374
- return result.ok ? {
3375
- ms: result.ms,
3376
- ok: true
3377
- } : {
3378
- detail: `http ${result.status}`,
3379
- ms: result.ms,
3380
- ok: false
3403
+ const probeResult = await probe(`${baseUrl}/healthz`, {}, localProbeTimeoutMs);
3404
+ const version = probeResult.body?.version;
3405
+ return {
3406
+ result: probeResult.ok ? {
3407
+ ms: probeResult.ms,
3408
+ ok: true
3409
+ } : {
3410
+ detail: `http ${probeResult.status}`,
3411
+ ms: probeResult.ms,
3412
+ ok: false
3413
+ },
3414
+ ...typeof version === "string" && version ? { version } : {}
3381
3415
  };
3382
3416
  } catch (error) {
3383
- return {
3417
+ return { result: {
3384
3418
  detail: describeError(error),
3385
3419
  ok: false
3386
- };
3420
+ } };
3387
3421
  }
3388
3422
  };
3389
3423
  const readModels = async (baseUrl) => {
@@ -3458,13 +3492,15 @@ const collectStatus = async (options) => {
3458
3492
  };
3459
3493
  const baseUrl = `http://${relay.host}:${relay.port}`;
3460
3494
  const health = await checkHealth(baseUrl);
3461
- const models = health.ok ? await readModels(baseUrl) : [];
3462
- const deep = options.deep && health.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
3495
+ const models = health.result.ok ? await readModels(baseUrl) : [];
3496
+ const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
3497
+ const daemonVersion = health.version ?? relay.version;
3463
3498
  return {
3464
3499
  ...base,
3465
3500
  baseUrl,
3501
+ ...daemonVersion ? { daemonVersion } : {},
3466
3502
  ...deep ? { deep } : {},
3467
- health,
3503
+ health: health.result,
3468
3504
  models,
3469
3505
  pid: relay.pid,
3470
3506
  port: relay.port,