copilot-relay 0.2.4 → 0.2.5

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
@@ -112,9 +112,32 @@ 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, `1` not
139
+ running, `2` running but the deep check failed.
140
+
118
141
  ## Logging
119
142
 
120
143
  At `debug`, every model request logs the requested model, upstream model, requested think effort, requested thinking, and effective think effort.
@@ -139,12 +162,23 @@ grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copi
139
162
 
140
163
  See [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
141
164
 
165
+ ## Running it as a service
166
+
167
+ To keep the relay running across reboots, see [`wiki/`](wiki/) — per-platform
168
+ setup for macOS, Windows, and Linux, in English and 中文, each covering
169
+ registration, how to verify it is *actually* working rather than merely
170
+ listening, and how to stop it.
171
+
172
+ The same pages are published to the [wiki tab](https://github.com/D0n9X1n/copilot-relay/wiki),
173
+ which is generated from `wiki/` on every merge. Edit the folder, not the tab.
174
+
142
175
  ## Development
143
176
 
144
177
  Developer notes live in [`docs/development.md`](docs/development.md).
145
178
  Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
146
179
  Logging notes live in [`docs/logging.md`](docs/logging.md).
147
180
  Troubleshooting notes live in [`docs/troubleshooting.md`](docs/troubleshooting.md).
181
+ User-facing guides live in [`wiki/`](wiki/).
148
182
 
149
183
  ```sh
150
184
  npm install
package/dist/main.js CHANGED
@@ -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();
@@ -2840,9 +2867,9 @@ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.fl
2840
2867
  const value = request.headers.get(name);
2841
2868
  return value ? [[name, value]] : [];
2842
2869
  }));
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;
2870
+ const formatStatusLog = (requestId, method, path$1, status$1, ms, errorMessage) => {
2871
+ const base = `request_id=${requestId} ${method} ${path$1} -> ${status$1} ${ms}ms`;
2872
+ return status$1 >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
2846
2873
  };
2847
2874
  const createServer = (config) => {
2848
2875
  const app = new Hono();
@@ -3020,6 +3047,195 @@ const restart = defineCommand({
3020
3047
  }
3021
3048
  });
3022
3049
 
3050
+ //#endregion
3051
+ //#region src/status.ts
3052
+ /**
3053
+ * Exit codes, so `status` is usable in a health check or a script.
3054
+ *
3055
+ * 2 is distinct from 1 on purpose: "running but broken" and "not running" call
3056
+ * for different responses - restart the service versus re-authenticate.
3057
+ */
3058
+ const exitCodes = {
3059
+ deepCheckFailed: 2,
3060
+ notRunning: 1,
3061
+ running: 0
3062
+ };
3063
+ const localProbeTimeoutMs = 5e3;
3064
+ const deepProbeTimeoutMs = 6e4;
3065
+ const formatUptime = (startedAt) => {
3066
+ if (!startedAt) return "unknown";
3067
+ const started = Date.parse(startedAt);
3068
+ if (Number.isNaN(started)) return "unknown";
3069
+ const seconds = Math.max(0, Math.floor((Date.now() - started) / 1e3));
3070
+ if (seconds < 60) return `${seconds}s`;
3071
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
3072
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
3073
+ return `${Math.floor(seconds / 86400)}d ${Math.floor(seconds % 86400 / 3600)}h`;
3074
+ };
3075
+ /**
3076
+ * Pure state-to-lines mapping, kept free of IO so it can be tested directly
3077
+ * without spawning a relay or opening a socket.
3078
+ */
3079
+ const renderStatus = (status$1) => {
3080
+ const lines = [`copilot-relay ${status$1.version}`];
3081
+ if (!status$1.running) {
3082
+ 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");
3083
+ return lines;
3084
+ }
3085
+ lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})`, ` listening ${status$1.baseUrl}`);
3086
+ const health = status$1.health;
3087
+ lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
3088
+ lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
3089
+ 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}` : ""}`);
3090
+ else lines.push(" upstream not checked (use --deep)");
3091
+ lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath} (logLevel=${status$1.logLevel}, thinkEffort=${status$1.thinkEffort})`);
3092
+ 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");
3093
+ return lines;
3094
+ };
3095
+ const probe = async (url, init, timeoutMs) => {
3096
+ const started = performance.now();
3097
+ const response = await fetch(url, {
3098
+ ...init,
3099
+ signal: AbortSignal.timeout(timeoutMs)
3100
+ });
3101
+ return {
3102
+ body: await response.json().catch(() => void 0),
3103
+ ms: Math.round(performance.now() - started),
3104
+ ok: response.ok,
3105
+ status: response.status
3106
+ };
3107
+ };
3108
+ const describeError = (error) => error instanceof Error ? error.name === "TimeoutError" ? "timed out" : error.message : String(error);
3109
+ const checkHealth = async (baseUrl) => {
3110
+ try {
3111
+ const result = await probe(`${baseUrl}/healthz`, {}, localProbeTimeoutMs);
3112
+ return result.ok ? {
3113
+ ms: result.ms,
3114
+ ok: true
3115
+ } : {
3116
+ detail: `http ${result.status}`,
3117
+ ms: result.ms,
3118
+ ok: false
3119
+ };
3120
+ } catch (error) {
3121
+ return {
3122
+ detail: describeError(error),
3123
+ ok: false
3124
+ };
3125
+ }
3126
+ };
3127
+ const readModels = async (baseUrl) => {
3128
+ try {
3129
+ const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
3130
+ return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
3131
+ } catch {
3132
+ return [];
3133
+ }
3134
+ };
3135
+ /**
3136
+ * Layer 3: a real request through Copilot.
3137
+ *
3138
+ * Opt-in because it spends tokens. It is the only check that exercises token
3139
+ * refresh, the upstream call, and translation back to Claude shape.
3140
+ */
3141
+ const checkDeep = async (baseUrl, model) => {
3142
+ try {
3143
+ const result = await probe(`${baseUrl}/v1/messages`, {
3144
+ body: JSON.stringify({
3145
+ max_tokens: 16,
3146
+ messages: [{
3147
+ content: "Reply with the single word: ok",
3148
+ role: "user"
3149
+ }],
3150
+ model
3151
+ }),
3152
+ headers: {
3153
+ "anthropic-version": "2023-06-01",
3154
+ "content-type": "application/json"
3155
+ },
3156
+ method: "POST"
3157
+ }, deepProbeTimeoutMs);
3158
+ if (!result.ok) {
3159
+ const message = result.body?.error?.message;
3160
+ return {
3161
+ detail: message ? `http ${result.status}: ${message}` : `http ${result.status}`,
3162
+ ms: result.ms,
3163
+ ok: false
3164
+ };
3165
+ }
3166
+ const content = result.body?.content;
3167
+ return Array.isArray(content) && content.length > 0 ? {
3168
+ ms: result.ms,
3169
+ ok: true
3170
+ } : {
3171
+ detail: "empty response",
3172
+ ms: result.ms,
3173
+ ok: false
3174
+ };
3175
+ } catch (error) {
3176
+ return {
3177
+ detail: describeError(error),
3178
+ ok: false
3179
+ };
3180
+ }
3181
+ };
3182
+ const collectStatus = async (options) => {
3183
+ const appConfig = await readAppConfig();
3184
+ setLogLevel(appConfig.logLevel);
3185
+ const base = {
3186
+ configPath: paths.configPath,
3187
+ logLevel: appConfig.logLevel,
3188
+ logPath: getLogPath(),
3189
+ thinkEffort: appConfig.thinkEffort,
3190
+ version: appVersion
3191
+ };
3192
+ const pids = await findRelayProcessIds(readProxyConfig(appConfig));
3193
+ if (pids.length === 0) return {
3194
+ ...base,
3195
+ running: false
3196
+ };
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}`;
3201
+ const health = await checkHealth(baseUrl);
3202
+ const models = health.ok ? await readModels(baseUrl) : [];
3203
+ const deep = options.deep && health.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
3204
+ return {
3205
+ ...base,
3206
+ baseUrl,
3207
+ ...deep ? { deep } : {},
3208
+ health,
3209
+ models,
3210
+ pid: entry?.pid ?? pids[0],
3211
+ port,
3212
+ running: true,
3213
+ ...entry?.startedAt ? { startedAt: entry.startedAt } : {}
3214
+ };
3215
+ };
3216
+ const status = defineCommand({
3217
+ meta: {
3218
+ name: "status",
3219
+ description: "Show whether copilot-relay is running and reachable."
3220
+ },
3221
+ args: {
3222
+ deep: {
3223
+ description: "Also send a real request through Copilot. Proves the relay works end to end; spends a few tokens.",
3224
+ type: "boolean"
3225
+ },
3226
+ json: {
3227
+ description: "Emit machine-readable JSON.",
3228
+ type: "boolean"
3229
+ }
3230
+ },
3231
+ async run({ args }) {
3232
+ const result = await collectStatus({ deep: Boolean(args.deep) });
3233
+ if (args.json) console.log(JSON.stringify(result, null, 2));
3234
+ else console.log(renderStatus(result).join("\n"));
3235
+ process.exitCode = !result.running ? exitCodes.notRunning : result.deep && !result.deep.ok ? exitCodes.deepCheckFailed : exitCodes.running;
3236
+ }
3237
+ });
3238
+
3023
3239
  //#endregion
3024
3240
  //#region src/stop.ts
3025
3241
  const stop = defineCommand({
@@ -3047,6 +3263,7 @@ await runMain(defineCommand({
3047
3263
  auth,
3048
3264
  restart,
3049
3265
  start,
3266
+ status,
3050
3267
  stop
3051
3268
  }
3052
3269
  }));