copilot-relay 0.2.4 → 0.2.6
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 +36 -0
- package/dist/main.js +260 -4
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,9 +112,34 @@ The same folder stores `copilot_token.json` for the cached Copilot bearer token,
|
|
|
112
112
|
copilot-relay auth
|
|
113
113
|
copilot-relay start
|
|
114
114
|
copilot-relay restart
|
|
115
|
+
copilot-relay status
|
|
115
116
|
copilot-relay stop
|
|
116
117
|
```
|
|
117
118
|
|
|
119
|
+
`status` reports whether a relay is running, where it is listening, and whether
|
|
120
|
+
it is reachable:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
copilot-relay 0.2.5
|
|
124
|
+
process running (pid 93744, up 1h 16m)
|
|
125
|
+
listening http://127.0.0.1:4142
|
|
126
|
+
health ok (9ms)
|
|
127
|
+
models gpt-5.6-sol[1m], claude-opus-5
|
|
128
|
+
upstream not checked (use --deep)
|
|
129
|
+
log ~/.copilot-relay/logs/copilot-relay.2026-07-25.log
|
|
130
|
+
config ~/.copilot-relay/config.yaml (logLevel=info, thinkEffort=max)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`--deep` additionally sends a real request through Copilot. That is the only
|
|
134
|
+
check that proves the relay can actually serve Claude Code — a relay whose
|
|
135
|
+
Copilot token expired an hour ago still answers `/healthz` and `/v1/models`,
|
|
136
|
+
because neither contacts upstream. It is opt-in because it spends a few tokens.
|
|
137
|
+
|
|
138
|
+
`--json` emits machine-readable output. Exit codes: `0` running and reachable,
|
|
139
|
+
`1` not running, `2` running but not usable — the health probe failed, or
|
|
140
|
+
`--deep` was requested and failed. `0` requires a live process *and* a passing
|
|
141
|
+
health probe, so a relay that cannot answer `/healthz` never reports success.
|
|
142
|
+
|
|
118
143
|
## Logging
|
|
119
144
|
|
|
120
145
|
At `debug`, every model request logs the requested model, upstream model, requested think effort, requested thinking, and effective think effort.
|
|
@@ -139,12 +164,23 @@ grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copi
|
|
|
139
164
|
|
|
140
165
|
See [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
|
|
141
166
|
|
|
167
|
+
## Running it as a service
|
|
168
|
+
|
|
169
|
+
To keep the relay running across reboots, see [`wiki/`](wiki/) — per-platform
|
|
170
|
+
setup for macOS, Windows, and Linux, in English and 中文, each covering
|
|
171
|
+
registration, how to verify it is *actually* working rather than merely
|
|
172
|
+
listening, and how to stop it.
|
|
173
|
+
|
|
174
|
+
The same pages are published to the [wiki tab](https://github.com/D0n9X1n/copilot-relay/wiki),
|
|
175
|
+
which is generated from `wiki/` on every merge. Edit the folder, not the tab.
|
|
176
|
+
|
|
142
177
|
## Development
|
|
143
178
|
|
|
144
179
|
Developer notes live in [`docs/development.md`](docs/development.md).
|
|
145
180
|
Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
|
|
146
181
|
Logging notes live in [`docs/logging.md`](docs/logging.md).
|
|
147
182
|
Troubleshooting notes live in [`docs/troubleshooting.md`](docs/troubleshooting.md).
|
|
183
|
+
User-facing guides live in [`wiki/`](wiki/).
|
|
148
184
|
|
|
149
185
|
```sh
|
|
150
186
|
npm install
|
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";
|
|
@@ -751,6 +751,33 @@ const isProcessAlive = (pid) => {
|
|
|
751
751
|
return isNodeErrno(error) && error.code === "EPERM";
|
|
752
752
|
}
|
|
753
753
|
};
|
|
754
|
+
/**
|
|
755
|
+
* Full pid-file record, for callers that need the listening address or start
|
|
756
|
+
* time rather than just the pid.
|
|
757
|
+
*
|
|
758
|
+
* Deliberately tolerant: a corrupt or half-written pid file yields undefined
|
|
759
|
+
* rather than throwing, because `status` must be able to report "not running"
|
|
760
|
+
* instead of crashing on a file the user may have been mid-edit on.
|
|
761
|
+
*
|
|
762
|
+
* Returns undefined for the legacy bare-pid format, which carries no address.
|
|
763
|
+
*/
|
|
764
|
+
const readRelayPidFileEntry = async () => {
|
|
765
|
+
try {
|
|
766
|
+
const trimmed = (await fs.readFile(paths.pidPath, "utf8")).trim();
|
|
767
|
+
if (!trimmed || !trimmed.startsWith("{")) return;
|
|
768
|
+
const payload = JSON.parse(trimmed);
|
|
769
|
+
const pid = parsePid(payload.pid);
|
|
770
|
+
if (pid === void 0 || typeof payload.host !== "string" || typeof payload.port !== "number") return;
|
|
771
|
+
return {
|
|
772
|
+
host: payload.host,
|
|
773
|
+
pid,
|
|
774
|
+
port: payload.port,
|
|
775
|
+
startedAt: typeof payload.startedAt === "string" ? payload.startedAt : ""
|
|
776
|
+
};
|
|
777
|
+
} catch {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
754
781
|
const readRelayPidFile = async () => {
|
|
755
782
|
try {
|
|
756
783
|
const trimmed = (await fs.readFile(paths.pidPath, "utf8")).trim();
|
|
@@ -837,6 +864,27 @@ const isRelayPid = async (pid) => {
|
|
|
837
864
|
if (!command) return false;
|
|
838
865
|
return isRelayStartProcess(command, await getProcessCwd(pid));
|
|
839
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
|
+
};
|
|
840
888
|
const findRelayProcessIds = async (config) => {
|
|
841
889
|
const candidates = /* @__PURE__ */ new Set();
|
|
842
890
|
const storedPid = await readRelayPidFile();
|
|
@@ -2840,9 +2888,9 @@ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.fl
|
|
|
2840
2888
|
const value = request.headers.get(name);
|
|
2841
2889
|
return value ? [[name, value]] : [];
|
|
2842
2890
|
}));
|
|
2843
|
-
const formatStatusLog = (requestId, method, path$1, status, ms, errorMessage) => {
|
|
2844
|
-
const base = `request_id=${requestId} ${method} ${path$1} -> ${status} ${ms}ms`;
|
|
2845
|
-
return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
|
|
2891
|
+
const formatStatusLog = (requestId, method, path$1, status$1, ms, errorMessage) => {
|
|
2892
|
+
const base = `request_id=${requestId} ${method} ${path$1} -> ${status$1} ${ms}ms`;
|
|
2893
|
+
return status$1 >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
|
|
2846
2894
|
};
|
|
2847
2895
|
const createServer = (config) => {
|
|
2848
2896
|
const app = new Hono();
|
|
@@ -2912,6 +2960,7 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
|
|
|
2912
2960
|
|
|
2913
2961
|
//#endregion
|
|
2914
2962
|
//#region src/start.ts
|
|
2963
|
+
const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
|
|
2915
2964
|
async function startRelay(appConfig) {
|
|
2916
2965
|
appConfig ??= await readAppConfig();
|
|
2917
2966
|
setLogLevel(appConfig.logLevel);
|
|
@@ -2970,6 +3019,7 @@ async function startRelay(appConfig) {
|
|
|
2970
3019
|
applyRuntimeConfig(nextConfig);
|
|
2971
3020
|
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
|
|
2972
3021
|
});
|
|
3022
|
+
const shutdownGraceMs = 2e3;
|
|
2973
3023
|
const shutdown = (signal) => {
|
|
2974
3024
|
log.info(`Received ${signal}; shutting down copilot-relay`);
|
|
2975
3025
|
server.close((error) => {
|
|
@@ -2978,6 +3028,13 @@ async function startRelay(appConfig) {
|
|
|
2978
3028
|
process.exitCode = 1;
|
|
2979
3029
|
}
|
|
2980
3030
|
});
|
|
3031
|
+
if (canCloseConnections(server)) {
|
|
3032
|
+
server.closeIdleConnections();
|
|
3033
|
+
setTimeout(() => {
|
|
3034
|
+
log.info("Grace period elapsed; closing remaining connections");
|
|
3035
|
+
server.closeAllConnections();
|
|
3036
|
+
}, shutdownGraceMs).unref();
|
|
3037
|
+
}
|
|
2981
3038
|
};
|
|
2982
3039
|
process.once("SIGINT", shutdown);
|
|
2983
3040
|
process.once("SIGTERM", shutdown);
|
|
@@ -3020,6 +3077,204 @@ const restart = defineCommand({
|
|
|
3020
3077
|
}
|
|
3021
3078
|
});
|
|
3022
3079
|
|
|
3080
|
+
//#endregion
|
|
3081
|
+
//#region src/status.ts
|
|
3082
|
+
/**
|
|
3083
|
+
* Exit codes, so `status` is usable in a health check or a script.
|
|
3084
|
+
*
|
|
3085
|
+
* 2 is distinct from 1 on purpose: "running but broken" and "not running" call
|
|
3086
|
+
* for different responses - restart the service versus re-authenticate.
|
|
3087
|
+
*
|
|
3088
|
+
* A failed health probe is a 2, not a 0. Printing FAILED while exiting 0 would
|
|
3089
|
+
* make every scripted caller treat an unreachable relay as fine. See #34.
|
|
3090
|
+
*/
|
|
3091
|
+
const exitCodes = {
|
|
3092
|
+
notRunning: 1,
|
|
3093
|
+
notUsable: 2,
|
|
3094
|
+
running: 0
|
|
3095
|
+
};
|
|
3096
|
+
const localProbeTimeoutMs = 5e3;
|
|
3097
|
+
const deepProbeTimeoutMs = 6e4;
|
|
3098
|
+
/**
|
|
3099
|
+
* Status to exit code. Pure, because this mapping is the contract every
|
|
3100
|
+
* scripted caller depends on and deserves direct coverage.
|
|
3101
|
+
*
|
|
3102
|
+
* 0 requires a live process *and* a passing health probe. Printing FAILED while
|
|
3103
|
+
* exiting 0 would make every scripted caller treat an unreachable relay as
|
|
3104
|
+
* fine. See #34.
|
|
3105
|
+
*/
|
|
3106
|
+
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;
|
|
3107
|
+
const formatUptime = (startedAt) => {
|
|
3108
|
+
if (!startedAt) return "unknown";
|
|
3109
|
+
const started = Date.parse(startedAt);
|
|
3110
|
+
if (Number.isNaN(started)) return "unknown";
|
|
3111
|
+
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1e3));
|
|
3112
|
+
if (seconds < 60) return `${seconds}s`;
|
|
3113
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
3114
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
|
|
3115
|
+
return `${Math.floor(seconds / 86400)}d ${Math.floor(seconds % 86400 / 3600)}h`;
|
|
3116
|
+
};
|
|
3117
|
+
/**
|
|
3118
|
+
* Pure state-to-lines mapping, kept free of IO so it can be tested directly
|
|
3119
|
+
* without spawning a relay or opening a socket.
|
|
3120
|
+
*/
|
|
3121
|
+
const renderStatus = (status$1) => {
|
|
3122
|
+
const lines = [`copilot-relay ${status$1.version}`];
|
|
3123
|
+
if (!status$1.running) {
|
|
3124
|
+
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");
|
|
3125
|
+
return lines;
|
|
3126
|
+
}
|
|
3127
|
+
lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})`, ` listening ${status$1.baseUrl}`);
|
|
3128
|
+
const health = status$1.health;
|
|
3129
|
+
lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
|
|
3130
|
+
lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
|
|
3131
|
+
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}` : ""}`);
|
|
3132
|
+
else lines.push(" upstream not checked (use --deep)");
|
|
3133
|
+
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath} (logLevel=${status$1.logLevel}, thinkEffort=${status$1.thinkEffort})`);
|
|
3134
|
+
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");
|
|
3135
|
+
return lines;
|
|
3136
|
+
};
|
|
3137
|
+
const probe = async (url, init, timeoutMs) => {
|
|
3138
|
+
const started = performance.now();
|
|
3139
|
+
const response = await fetch(url, {
|
|
3140
|
+
...init,
|
|
3141
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3142
|
+
});
|
|
3143
|
+
return {
|
|
3144
|
+
body: await response.json().catch(() => void 0),
|
|
3145
|
+
ms: Math.round(performance.now() - started),
|
|
3146
|
+
ok: response.ok,
|
|
3147
|
+
status: response.status
|
|
3148
|
+
};
|
|
3149
|
+
};
|
|
3150
|
+
const describeError = (error) => error instanceof Error ? error.name === "TimeoutError" ? "timed out" : error.message : String(error);
|
|
3151
|
+
const checkHealth = async (baseUrl) => {
|
|
3152
|
+
try {
|
|
3153
|
+
const result = await probe(`${baseUrl}/healthz`, {}, localProbeTimeoutMs);
|
|
3154
|
+
return result.ok ? {
|
|
3155
|
+
ms: result.ms,
|
|
3156
|
+
ok: true
|
|
3157
|
+
} : {
|
|
3158
|
+
detail: `http ${result.status}`,
|
|
3159
|
+
ms: result.ms,
|
|
3160
|
+
ok: false
|
|
3161
|
+
};
|
|
3162
|
+
} catch (error) {
|
|
3163
|
+
return {
|
|
3164
|
+
detail: describeError(error),
|
|
3165
|
+
ok: false
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
const readModels = async (baseUrl) => {
|
|
3170
|
+
try {
|
|
3171
|
+
const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
|
|
3172
|
+
return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
|
|
3173
|
+
} catch {
|
|
3174
|
+
return [];
|
|
3175
|
+
}
|
|
3176
|
+
};
|
|
3177
|
+
/**
|
|
3178
|
+
* Layer 3: a real request through Copilot.
|
|
3179
|
+
*
|
|
3180
|
+
* Opt-in because it spends tokens. It is the only check that exercises token
|
|
3181
|
+
* refresh, the upstream call, and translation back to Claude shape.
|
|
3182
|
+
*/
|
|
3183
|
+
const checkDeep = async (baseUrl, model) => {
|
|
3184
|
+
try {
|
|
3185
|
+
const result = await probe(`${baseUrl}/v1/messages`, {
|
|
3186
|
+
body: JSON.stringify({
|
|
3187
|
+
max_tokens: 16,
|
|
3188
|
+
messages: [{
|
|
3189
|
+
content: "Reply with the single word: ok",
|
|
3190
|
+
role: "user"
|
|
3191
|
+
}],
|
|
3192
|
+
model
|
|
3193
|
+
}),
|
|
3194
|
+
headers: {
|
|
3195
|
+
"anthropic-version": "2023-06-01",
|
|
3196
|
+
"content-type": "application/json"
|
|
3197
|
+
},
|
|
3198
|
+
method: "POST"
|
|
3199
|
+
}, deepProbeTimeoutMs);
|
|
3200
|
+
if (!result.ok) {
|
|
3201
|
+
const message = result.body?.error?.message;
|
|
3202
|
+
return {
|
|
3203
|
+
detail: message ? `http ${result.status}: ${message}` : `http ${result.status}`,
|
|
3204
|
+
ms: result.ms,
|
|
3205
|
+
ok: false
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
const content = result.body?.content;
|
|
3209
|
+
return Array.isArray(content) && content.length > 0 ? {
|
|
3210
|
+
ms: result.ms,
|
|
3211
|
+
ok: true
|
|
3212
|
+
} : {
|
|
3213
|
+
detail: "empty response",
|
|
3214
|
+
ms: result.ms,
|
|
3215
|
+
ok: false
|
|
3216
|
+
};
|
|
3217
|
+
} catch (error) {
|
|
3218
|
+
return {
|
|
3219
|
+
detail: describeError(error),
|
|
3220
|
+
ok: false
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
};
|
|
3224
|
+
const collectStatus = async (options) => {
|
|
3225
|
+
const appConfig = await readAppConfig();
|
|
3226
|
+
setLogLevel(appConfig.logLevel);
|
|
3227
|
+
const base = {
|
|
3228
|
+
configPath: paths.configPath,
|
|
3229
|
+
logLevel: appConfig.logLevel,
|
|
3230
|
+
logPath: getLogPath(),
|
|
3231
|
+
thinkEffort: appConfig.thinkEffort,
|
|
3232
|
+
version: appVersion
|
|
3233
|
+
};
|
|
3234
|
+
const relay = await findRelayOnPort(readProxyConfig(appConfig));
|
|
3235
|
+
if (!relay) return {
|
|
3236
|
+
...base,
|
|
3237
|
+
running: false
|
|
3238
|
+
};
|
|
3239
|
+
const baseUrl = `http://${relay.host}:${relay.port}`;
|
|
3240
|
+
const health = await checkHealth(baseUrl);
|
|
3241
|
+
const models = health.ok ? await readModels(baseUrl) : [];
|
|
3242
|
+
const deep = options.deep && health.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
|
|
3243
|
+
return {
|
|
3244
|
+
...base,
|
|
3245
|
+
baseUrl,
|
|
3246
|
+
...deep ? { deep } : {},
|
|
3247
|
+
health,
|
|
3248
|
+
models,
|
|
3249
|
+
pid: relay.pid,
|
|
3250
|
+
port: relay.port,
|
|
3251
|
+
running: true,
|
|
3252
|
+
...relay.startedAt ? { startedAt: relay.startedAt } : {}
|
|
3253
|
+
};
|
|
3254
|
+
};
|
|
3255
|
+
const status = defineCommand({
|
|
3256
|
+
meta: {
|
|
3257
|
+
name: "status",
|
|
3258
|
+
description: "Show whether copilot-relay is running and reachable."
|
|
3259
|
+
},
|
|
3260
|
+
args: {
|
|
3261
|
+
deep: {
|
|
3262
|
+
description: "Also send a real request through Copilot. Proves the relay works end to end; spends a few tokens.",
|
|
3263
|
+
type: "boolean"
|
|
3264
|
+
},
|
|
3265
|
+
json: {
|
|
3266
|
+
description: "Emit machine-readable JSON.",
|
|
3267
|
+
type: "boolean"
|
|
3268
|
+
}
|
|
3269
|
+
},
|
|
3270
|
+
async run({ args }) {
|
|
3271
|
+
const result = await collectStatus({ deep: Boolean(args.deep) });
|
|
3272
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
3273
|
+
else console.log(renderStatus(result).join("\n"));
|
|
3274
|
+
process.exitCode = resolveExitCode(result);
|
|
3275
|
+
}
|
|
3276
|
+
});
|
|
3277
|
+
|
|
3023
3278
|
//#endregion
|
|
3024
3279
|
//#region src/stop.ts
|
|
3025
3280
|
const stop = defineCommand({
|
|
@@ -3047,6 +3302,7 @@ await runMain(defineCommand({
|
|
|
3047
3302
|
auth,
|
|
3048
3303
|
restart,
|
|
3049
3304
|
start,
|
|
3305
|
+
status,
|
|
3050
3306
|
stop
|
|
3051
3307
|
}
|
|
3052
3308
|
}));
|