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 +9 -2
- package/dist/main.js +81 -31
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
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
|
|
139
|
-
running, `2` running but the
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
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
|
|
2445
|
-
tool_choice:
|
|
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
|
|
3193
|
-
if (
|
|
3245
|
+
const relay = await findRelayOnPort(readProxyConfig(appConfig));
|
|
3246
|
+
if (!relay) return {
|
|
3194
3247
|
...base,
|
|
3195
3248
|
running: false
|
|
3196
3249
|
};
|
|
3197
|
-
const
|
|
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:
|
|
3211
|
-
port,
|
|
3260
|
+
pid: relay.pid,
|
|
3261
|
+
port: relay.port,
|
|
3212
3262
|
running: true,
|
|
3213
|
-
...
|
|
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 =
|
|
3285
|
+
process.exitCode = resolveExitCode(result);
|
|
3236
3286
|
}
|
|
3237
3287
|
});
|
|
3238
3288
|
|