copilot-relay 0.3.0 → 0.3.2
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 +38 -1
- package/dist/main.js +124 -38
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -134,14 +134,51 @@ 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
|
|
140
141
|
upstream not checked (use --deep)
|
|
141
142
|
log ~/.copilot-relay/logs/copilot-relay.2026-07-25.log
|
|
142
|
-
config ~/.copilot-relay/config.yaml
|
|
143
|
+
config ~/.copilot-relay/config.yaml
|
|
144
|
+
host 127.0.0.1
|
|
145
|
+
port 4142
|
|
146
|
+
copilotBaseUrl https://api.githubcopilot.com
|
|
147
|
+
claudeSetup true
|
|
148
|
+
logLevel info
|
|
149
|
+
logRetentionDays 3
|
|
150
|
+
thinkEffort max
|
|
151
|
+
upstreamTimeoutSeconds 180
|
|
152
|
+
webSearchBackend (unset — uses gptModel)
|
|
153
|
+
gptModel gpt-5.6-sol
|
|
154
|
+
opusModel claude-opus-5
|
|
155
|
+
host, port and claudeSetup take effect on restart; the rest hot-reload.
|
|
143
156
|
```
|
|
144
157
|
|
|
158
|
+
The `config` block is every key `readAppConfig()` resolved, in
|
|
159
|
+
`config.default.yaml` order. These are the values on disk: eight of them reload
|
|
160
|
+
while the relay runs, while `host`, `port` and `claudeSetup` are read once at
|
|
161
|
+
startup — the block says so rather than leaving you to discover it after an
|
|
162
|
+
edit. `--json` emits the same values under `config`, with `webSearchBackend` as
|
|
163
|
+
`null` when unset so the key set stays the same shape either way.
|
|
164
|
+
|
|
165
|
+
The first line is the CLI you invoked; the `version` row is the build the
|
|
166
|
+
running daemon reports about itself. They differ after an upgrade that has not
|
|
167
|
+
been restarted yet — the new CLI is installed, the old process is still
|
|
168
|
+
serving — and `status` says so rather than letting the header imply the upgrade
|
|
169
|
+
took effect:
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
version 0.2.6 — MISMATCH, 0.3.0 is installed
|
|
173
|
+
...
|
|
174
|
+
The running relay is 0.2.6; 0.3.0 is installed.
|
|
175
|
+
Restart it to serve the installed version: copilot-relay restart
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A mismatch does not change the exit code: the relay works, it is just not the
|
|
179
|
+
build you installed. A daemon older than v0.3.1 does not report a version at
|
|
180
|
+
all, which shows as `unknown`.
|
|
181
|
+
|
|
145
182
|
`--deep` additionally sends a real request through Copilot. That is the only
|
|
146
183
|
check that proves the relay can actually serve Claude Code — a relay whose
|
|
147
184
|
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({
|
|
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) => {
|
|
@@ -3315,6 +3320,19 @@ const exitCodes = {
|
|
|
3315
3320
|
};
|
|
3316
3321
|
const localProbeTimeoutMs = 5e3;
|
|
3317
3322
|
const deepProbeTimeoutMs = 6e4;
|
|
3323
|
+
const toStatusConfig = (config) => ({
|
|
3324
|
+
...config,
|
|
3325
|
+
webSearchBackend: config.webSearchBackend ?? null
|
|
3326
|
+
});
|
|
3327
|
+
/**
|
|
3328
|
+
* True when the daemon is serving a different build than the invoked CLI.
|
|
3329
|
+
*
|
|
3330
|
+
* Pure and exported because it drives both the rendered warning and the
|
|
3331
|
+
* deliberate decision to leave the exit code at 0; both deserve direct
|
|
3332
|
+
* coverage. Undefined daemonVersion is not a mismatch - unknown is not
|
|
3333
|
+
* different, and claiming otherwise would warn on every pre-v0.3.1 daemon.
|
|
3334
|
+
*/
|
|
3335
|
+
const hasVersionMismatch = (status$1) => status$1.running && status$1.daemonVersion !== void 0 && status$1.daemonVersion !== status$1.version;
|
|
3318
3336
|
/**
|
|
3319
3337
|
* Status to exit code. Pure, because this mapping is the contract every
|
|
3320
3338
|
* scripted caller depends on and deserves direct coverage.
|
|
@@ -3322,6 +3340,11 @@ const deepProbeTimeoutMs = 6e4;
|
|
|
3322
3340
|
* 0 requires a live process *and* a passing health probe. Printing FAILED while
|
|
3323
3341
|
* exiting 0 would make every scripted caller treat an unreachable relay as
|
|
3324
3342
|
* fine. See #34.
|
|
3343
|
+
*
|
|
3344
|
+
* A version mismatch stays 0 on purpose (#43). The relay works; it is just not
|
|
3345
|
+
* the build that was installed. Mapping it to 2 would overstate it and break
|
|
3346
|
+
* every scripted caller that treats non-zero as broken. It is surfaced in the
|
|
3347
|
+
* text instead.
|
|
3325
3348
|
*/
|
|
3326
3349
|
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
3350
|
const formatUptime = (startedAt) => {
|
|
@@ -3335,23 +3358,71 @@ const formatUptime = (startedAt) => {
|
|
|
3335
3358
|
return `${Math.floor(seconds / 86400)}d ${Math.floor(seconds % 86400 / 3600)}h`;
|
|
3336
3359
|
};
|
|
3337
3360
|
/**
|
|
3361
|
+
* Every config key with its position in the block, in config.default.yaml order
|
|
3362
|
+
* rather than alphabetical so it reads like the file it was resolved from.
|
|
3363
|
+
*
|
|
3364
|
+
* A Record keyed by StatusConfig rather than an array of keys: an array only
|
|
3365
|
+
* checks that each entry *is* a key, so a twelfth config key would type-check
|
|
3366
|
+
* while silently never appearing in `status` — the exact failure this command
|
|
3367
|
+
* exists to prevent. Keyed this way, adding one fails the build here.
|
|
3368
|
+
*/
|
|
3369
|
+
const configRowOrder = {
|
|
3370
|
+
host: 0,
|
|
3371
|
+
port: 1,
|
|
3372
|
+
copilotBaseUrl: 2,
|
|
3373
|
+
claudeSetup: 3,
|
|
3374
|
+
logLevel: 4,
|
|
3375
|
+
logRetentionDays: 5,
|
|
3376
|
+
thinkEffort: 6,
|
|
3377
|
+
upstreamTimeoutSeconds: 7,
|
|
3378
|
+
webSearchBackend: 8,
|
|
3379
|
+
gptModel: 9,
|
|
3380
|
+
opusModel: 10
|
|
3381
|
+
};
|
|
3382
|
+
const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[a] - configRowOrder[b]);
|
|
3383
|
+
/**
|
|
3384
|
+
* The resolved config, one key per line.
|
|
3385
|
+
*
|
|
3386
|
+
* These are the values on disk. That is not the same question as "what is the
|
|
3387
|
+
* running daemon honouring", and the footnote says so rather than leaving it
|
|
3388
|
+
* implied: applyRuntimeConfig() in start.ts hot-reloads eight of these keys,
|
|
3389
|
+
* never reads claudeSetup, and deliberately does not rebind the listening
|
|
3390
|
+
* socket when host or port changes. Printing all eleven without that line
|
|
3391
|
+
* would imply a live daemon had read values it has not.
|
|
3392
|
+
*
|
|
3393
|
+
* Only shown when running, because with nothing up every value applies at the
|
|
3394
|
+
* next start and the note would be noise.
|
|
3395
|
+
*/
|
|
3396
|
+
const renderConfig = (config, running) => {
|
|
3397
|
+
const lines = configRowKeys.map((key) => {
|
|
3398
|
+
const value = config[key];
|
|
3399
|
+
const rendered = key === "webSearchBackend" && value === null ? "(unset — uses gptModel)" : String(value);
|
|
3400
|
+
return ` ${key.padEnd(24)}${rendered}`;
|
|
3401
|
+
});
|
|
3402
|
+
if (running) lines.push(" host, port and claudeSetup take effect on restart; the rest hot-reload.");
|
|
3403
|
+
return lines;
|
|
3404
|
+
};
|
|
3405
|
+
/**
|
|
3338
3406
|
* Pure state-to-lines mapping, kept free of IO so it can be tested directly
|
|
3339
3407
|
* without spawning a relay or opening a socket.
|
|
3340
3408
|
*/
|
|
3341
3409
|
const renderStatus = (status$1) => {
|
|
3342
3410
|
const lines = [`copilot-relay ${status$1.version}`];
|
|
3343
3411
|
if (!status$1.running) {
|
|
3344
|
-
lines.push(" process not running", `
|
|
3412
|
+
lines.push(" process not running", ` log ${status$1.logPath}`, ` config ${status$1.configPath}`, ...renderConfig(status$1.config, false), "", " Start it with: copilot-relay start");
|
|
3345
3413
|
return lines;
|
|
3346
3414
|
}
|
|
3347
|
-
lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})
|
|
3415
|
+
lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})`);
|
|
3416
|
+
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}`);
|
|
3417
|
+
lines.push(` listening ${status$1.baseUrl}`);
|
|
3348
3418
|
const health = status$1.health;
|
|
3349
3419
|
lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
|
|
3350
3420
|
lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
|
|
3351
3421
|
if (status$1.deep) lines.push(status$1.deep.ok ? ` upstream ok (${status$1.deep.ms}ms) — end-to-end Copilot round trip` : ` upstream FAILED${status$1.deep.detail ? ` — ${status$1.deep.detail}` : ""}`);
|
|
3352
3422
|
else lines.push(" upstream not checked (use --deep)");
|
|
3353
|
-
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath} (
|
|
3423
|
+
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath}`, ...renderConfig(status$1.config, true));
|
|
3354
3424
|
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");
|
|
3425
|
+
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
3426
|
return lines;
|
|
3356
3427
|
};
|
|
3357
3428
|
const probe = async (url, init, timeoutMs) => {
|
|
@@ -3368,22 +3439,34 @@ const probe = async (url, init, timeoutMs) => {
|
|
|
3368
3439
|
};
|
|
3369
3440
|
};
|
|
3370
3441
|
const describeError = (error) => error instanceof Error ? error.name === "TimeoutError" ? "timed out" : error.message : String(error);
|
|
3442
|
+
/**
|
|
3443
|
+
* Probes /healthz, which also reports the daemon's own version.
|
|
3444
|
+
*
|
|
3445
|
+
* The version rides along on a probe `status` already makes, so asking what is
|
|
3446
|
+
* running costs nothing extra. Absent for a daemon older than v0.3.1, which is
|
|
3447
|
+
* itself informative, so it stays optional rather than defaulting to the
|
|
3448
|
+
* caller's version - that substitution is the bug. See #43.
|
|
3449
|
+
*/
|
|
3371
3450
|
const checkHealth = async (baseUrl) => {
|
|
3372
3451
|
try {
|
|
3373
|
-
const
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3452
|
+
const probeResult = await probe(`${baseUrl}/healthz`, {}, localProbeTimeoutMs);
|
|
3453
|
+
const version = probeResult.body?.version;
|
|
3454
|
+
return {
|
|
3455
|
+
result: probeResult.ok ? {
|
|
3456
|
+
ms: probeResult.ms,
|
|
3457
|
+
ok: true
|
|
3458
|
+
} : {
|
|
3459
|
+
detail: `http ${probeResult.status}`,
|
|
3460
|
+
ms: probeResult.ms,
|
|
3461
|
+
ok: false
|
|
3462
|
+
},
|
|
3463
|
+
...typeof version === "string" && version ? { version } : {}
|
|
3381
3464
|
};
|
|
3382
3465
|
} catch (error) {
|
|
3383
|
-
return {
|
|
3466
|
+
return { result: {
|
|
3384
3467
|
detail: describeError(error),
|
|
3385
3468
|
ok: false
|
|
3386
|
-
};
|
|
3469
|
+
} };
|
|
3387
3470
|
}
|
|
3388
3471
|
};
|
|
3389
3472
|
const readModels = async (baseUrl) => {
|
|
@@ -3445,6 +3528,7 @@ const collectStatus = async (options) => {
|
|
|
3445
3528
|
const appConfig = await readAppConfig();
|
|
3446
3529
|
setLogLevel(appConfig.logLevel);
|
|
3447
3530
|
const base = {
|
|
3531
|
+
config: toStatusConfig(appConfig),
|
|
3448
3532
|
configPath: paths.configPath,
|
|
3449
3533
|
logLevel: appConfig.logLevel,
|
|
3450
3534
|
logPath: getLogPath(),
|
|
@@ -3458,13 +3542,15 @@ const collectStatus = async (options) => {
|
|
|
3458
3542
|
};
|
|
3459
3543
|
const baseUrl = `http://${relay.host}:${relay.port}`;
|
|
3460
3544
|
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;
|
|
3545
|
+
const models = health.result.ok ? await readModels(baseUrl) : [];
|
|
3546
|
+
const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
|
|
3547
|
+
const daemonVersion = health.version ?? relay.version;
|
|
3463
3548
|
return {
|
|
3464
3549
|
...base,
|
|
3465
3550
|
baseUrl,
|
|
3551
|
+
...daemonVersion ? { daemonVersion } : {},
|
|
3466
3552
|
...deep ? { deep } : {},
|
|
3467
|
-
health,
|
|
3553
|
+
health: health.result,
|
|
3468
3554
|
models,
|
|
3469
3555
|
pid: relay.pid,
|
|
3470
3556
|
port: relay.port,
|