copilot-relay 0.2.7 → 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 +28 -3
- package/dist/main.js +295 -50
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,9 +33,16 @@ relay is listening and nothing more.
|
|
|
33
33
|
Claude WebSearch is bridge-managed: when the model selects the WebSearch tool,
|
|
34
34
|
the relay executes Copilot `/responses` with `web_search_preview`, then sends the
|
|
35
35
|
retrieved context through a final model pass and returns Claude
|
|
36
|
-
`server_tool_use` / `web_search_tool_result` blocks.
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
`server_tool_use` / `web_search_tool_result` blocks. The final pass keeps your
|
|
37
|
+
other tools available, so the model can act on what it found in the same turn.
|
|
38
|
+
|
|
39
|
+
Advertising WebSearch no longer costs streaming. The relay reads the model's
|
|
40
|
+
response only as far as it takes to tell whether a search is coming, so a turn
|
|
41
|
+
that never searches streams normally — which is most of them, since Claude Code
|
|
42
|
+
offers the tool on every request.
|
|
43
|
+
|
|
44
|
+
Unknown API routes return `500` and log method, path, selected headers, and
|
|
45
|
+
request payload to help implement compatible endpoints later.
|
|
39
46
|
|
|
40
47
|
Routing:
|
|
41
48
|
|
|
@@ -127,6 +134,7 @@ it is reachable:
|
|
|
127
134
|
```text
|
|
128
135
|
copilot-relay 0.2.5
|
|
129
136
|
process running (pid 93744, up 1h 16m)
|
|
137
|
+
version 0.2.5
|
|
130
138
|
listening http://127.0.0.1:4142
|
|
131
139
|
health ok (9ms)
|
|
132
140
|
models gpt-5.6-sol[1m], claude-opus-5
|
|
@@ -135,6 +143,23 @@ copilot-relay 0.2.5
|
|
|
135
143
|
config ~/.copilot-relay/config.yaml (logLevel=info, thinkEffort=max)
|
|
136
144
|
```
|
|
137
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
|
+
|
|
138
163
|
`--deep` additionally sends a real request through Copilot. That is the only
|
|
139
164
|
check that proves the relay can actually serve Claude Code — a relay whose
|
|
140
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) {
|
|
@@ -2488,6 +2490,137 @@ const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
|
|
|
2488
2490
|
}
|
|
2489
2491
|
});
|
|
2490
2492
|
|
|
2493
|
+
//#endregion
|
|
2494
|
+
//#region src/claude/web-search-stream.ts
|
|
2495
|
+
const isWebSearchName = (name, toolNameMapper, isWebSearchToolName) => !!name && isWebSearchToolName(toolNameMapper.toClaude(name));
|
|
2496
|
+
const chunkSettlesDecision = (chunk) => {
|
|
2497
|
+
const choice = chunk.choices[0];
|
|
2498
|
+
if (!choice) return false;
|
|
2499
|
+
return choice.delta?.tool_calls?.some((call) => call.function?.name) === true || !!choice.finish_reason;
|
|
2500
|
+
};
|
|
2501
|
+
const chunkHasWebSearchCall = (chunk, toolNameMapper, isWebSearchToolName) => chunk.choices[0]?.delta?.tool_calls?.some((call) => isWebSearchName(call.function?.name, toolNameMapper, isWebSearchToolName)) ?? false;
|
|
2502
|
+
const accumulateChunks = (chunks) => {
|
|
2503
|
+
const first = chunks[0];
|
|
2504
|
+
const toolCalls = [];
|
|
2505
|
+
let content = "";
|
|
2506
|
+
let reasoning = "";
|
|
2507
|
+
let finishReason = null;
|
|
2508
|
+
let usage;
|
|
2509
|
+
for (const chunk of chunks) {
|
|
2510
|
+
if (chunk.usage) usage = chunk.usage;
|
|
2511
|
+
const choice = chunk.choices[0];
|
|
2512
|
+
if (!choice) continue;
|
|
2513
|
+
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
2514
|
+
const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
|
|
2515
|
+
if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
|
|
2516
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
2517
|
+
for (const call of choice.delta?.tool_calls ?? []) {
|
|
2518
|
+
const existing = toolCalls[call.index];
|
|
2519
|
+
if (existing) {
|
|
2520
|
+
existing.function.arguments += call.function?.arguments ?? "";
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
toolCalls[call.index] = {
|
|
2524
|
+
id: call.id ?? `call_${call.index}`,
|
|
2525
|
+
type: "function",
|
|
2526
|
+
function: {
|
|
2527
|
+
name: call.function?.name ?? "",
|
|
2528
|
+
arguments: call.function?.arguments ?? ""
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
const collectedToolCalls = toolCalls.filter(Boolean);
|
|
2534
|
+
return {
|
|
2535
|
+
id: first?.id ?? "chat_stream_accumulated",
|
|
2536
|
+
object: "chat.completion",
|
|
2537
|
+
created: first?.created ?? 0,
|
|
2538
|
+
model: first?.model ?? "",
|
|
2539
|
+
choices: [{
|
|
2540
|
+
index: 0,
|
|
2541
|
+
message: {
|
|
2542
|
+
role: "assistant",
|
|
2543
|
+
content: content || null,
|
|
2544
|
+
...reasoning ? { reasoning_text: reasoning } : {},
|
|
2545
|
+
...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
|
|
2546
|
+
},
|
|
2547
|
+
finish_reason: finishReason ?? "stop"
|
|
2548
|
+
}],
|
|
2549
|
+
...usage ? { usage } : {}
|
|
2550
|
+
};
|
|
2551
|
+
};
|
|
2552
|
+
const resolveWebSearchStreamDecision = async (stream, toolNameMapper, isWebSearchToolName, onChunk) => {
|
|
2553
|
+
const buffered = [];
|
|
2554
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
2555
|
+
let sawWebSearch = false;
|
|
2556
|
+
let settled = false;
|
|
2557
|
+
let done = false;
|
|
2558
|
+
let alreadyStreamed = false;
|
|
2559
|
+
while (!settled) {
|
|
2560
|
+
const next = await iterator.next();
|
|
2561
|
+
if (next.done) {
|
|
2562
|
+
done = true;
|
|
2563
|
+
break;
|
|
2564
|
+
}
|
|
2565
|
+
const raw = next.value;
|
|
2566
|
+
if (raw.data === "[DONE]") {
|
|
2567
|
+
done = true;
|
|
2568
|
+
break;
|
|
2569
|
+
}
|
|
2570
|
+
if (!raw.data) continue;
|
|
2571
|
+
let chunk;
|
|
2572
|
+
try {
|
|
2573
|
+
chunk = JSON.parse(raw.data);
|
|
2574
|
+
} catch {
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
buffered.push(chunk);
|
|
2578
|
+
if (chunkHasWebSearchCall(chunk, toolNameMapper, isWebSearchToolName)) {
|
|
2579
|
+
sawWebSearch = true;
|
|
2580
|
+
settled = true;
|
|
2581
|
+
break;
|
|
2582
|
+
}
|
|
2583
|
+
if (onChunk) {
|
|
2584
|
+
await onChunk(chunk);
|
|
2585
|
+
alreadyStreamed = true;
|
|
2586
|
+
}
|
|
2587
|
+
if (chunkSettlesDecision(chunk)) settled = true;
|
|
2588
|
+
}
|
|
2589
|
+
const rest = { async *[Symbol.asyncIterator]() {
|
|
2590
|
+
if (done) return;
|
|
2591
|
+
while (true) {
|
|
2592
|
+
const next = await iterator.next();
|
|
2593
|
+
if (next.done) return;
|
|
2594
|
+
yield next.value;
|
|
2595
|
+
}
|
|
2596
|
+
} };
|
|
2597
|
+
if (!sawWebSearch) return {
|
|
2598
|
+
decision: {
|
|
2599
|
+
kind: "streamed",
|
|
2600
|
+
buffered: onChunk ? [] : buffered
|
|
2601
|
+
},
|
|
2602
|
+
rest,
|
|
2603
|
+
alreadyStreamed
|
|
2604
|
+
};
|
|
2605
|
+
for await (const raw of rest) {
|
|
2606
|
+
if (raw.data === "[DONE]") break;
|
|
2607
|
+
if (!raw.data) continue;
|
|
2608
|
+
try {
|
|
2609
|
+
buffered.push(JSON.parse(raw.data));
|
|
2610
|
+
} catch {
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
return {
|
|
2615
|
+
decision: {
|
|
2616
|
+
kind: "webSearch",
|
|
2617
|
+
response: accumulateChunks(buffered)
|
|
2618
|
+
},
|
|
2619
|
+
rest: { async *[Symbol.asyncIterator]() {} },
|
|
2620
|
+
alreadyStreamed
|
|
2621
|
+
};
|
|
2622
|
+
};
|
|
2623
|
+
|
|
2491
2624
|
//#endregion
|
|
2492
2625
|
//#region src/lib/tokenizer.ts
|
|
2493
2626
|
const encodingMap = {
|
|
@@ -2716,6 +2849,65 @@ const eventsFromClaudeResponse = (response) => {
|
|
|
2716
2849
|
events$1.push({ type: "message_stop" });
|
|
2717
2850
|
return events$1;
|
|
2718
2851
|
};
|
|
2852
|
+
const continuationEventsFromClaudeResponse = (response, state) => {
|
|
2853
|
+
const events$1 = [];
|
|
2854
|
+
if (state.contentBlockOpen) {
|
|
2855
|
+
events$1.push({
|
|
2856
|
+
type: "content_block_stop",
|
|
2857
|
+
index: state.contentBlockIndex
|
|
2858
|
+
});
|
|
2859
|
+
state.contentBlockIndex += 1;
|
|
2860
|
+
state.contentBlockOpen = false;
|
|
2861
|
+
state.thinkingBlockOpen = false;
|
|
2862
|
+
}
|
|
2863
|
+
for (const block of response.content) {
|
|
2864
|
+
const index = state.contentBlockIndex;
|
|
2865
|
+
events$1.push({
|
|
2866
|
+
type: "content_block_start",
|
|
2867
|
+
index,
|
|
2868
|
+
content_block: block
|
|
2869
|
+
});
|
|
2870
|
+
if (block.type === "text") events$1.push({
|
|
2871
|
+
type: "content_block_delta",
|
|
2872
|
+
index,
|
|
2873
|
+
delta: {
|
|
2874
|
+
type: "text_delta",
|
|
2875
|
+
text: block.text
|
|
2876
|
+
}
|
|
2877
|
+
});
|
|
2878
|
+
else if (block.type === "thinking") events$1.push({
|
|
2879
|
+
type: "content_block_delta",
|
|
2880
|
+
index,
|
|
2881
|
+
delta: {
|
|
2882
|
+
type: "thinking_delta",
|
|
2883
|
+
thinking: block.thinking
|
|
2884
|
+
}
|
|
2885
|
+
});
|
|
2886
|
+
else if (block.type === "tool_use" || block.type === "server_tool_use") events$1.push({
|
|
2887
|
+
type: "content_block_delta",
|
|
2888
|
+
index,
|
|
2889
|
+
delta: {
|
|
2890
|
+
type: "input_json_delta",
|
|
2891
|
+
partial_json: JSON.stringify(block.input)
|
|
2892
|
+
}
|
|
2893
|
+
});
|
|
2894
|
+
events$1.push({
|
|
2895
|
+
type: "content_block_stop",
|
|
2896
|
+
index
|
|
2897
|
+
});
|
|
2898
|
+
state.contentBlockIndex += 1;
|
|
2899
|
+
}
|
|
2900
|
+
events$1.push({
|
|
2901
|
+
type: "message_delta",
|
|
2902
|
+
delta: {
|
|
2903
|
+
stop_reason: response.stop_reason,
|
|
2904
|
+
stop_sequence: response.stop_sequence
|
|
2905
|
+
},
|
|
2906
|
+
usage: { output_tokens: response.usage.output_tokens }
|
|
2907
|
+
});
|
|
2908
|
+
events$1.push({ type: "message_stop" });
|
|
2909
|
+
return events$1;
|
|
2910
|
+
};
|
|
2719
2911
|
const createQueuedClaudeStreamWriter = (write) => {
|
|
2720
2912
|
let pending = Promise.resolve();
|
|
2721
2913
|
return (event) => {
|
|
@@ -2732,7 +2924,8 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2732
2924
|
const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
|
|
2733
2925
|
const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
|
|
2734
2926
|
const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
|
|
2735
|
-
|
|
2927
|
+
const canStreamWebSearchDecision = shouldLetModelDecideWebSearch && !!writeEvent;
|
|
2928
|
+
if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
|
|
2736
2929
|
const response = await createChatCompletions(config, openAIPayload, {
|
|
2737
2930
|
client: "claude",
|
|
2738
2931
|
requestedModel: claudePayload.model,
|
|
@@ -2742,8 +2935,31 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2742
2935
|
signal: requestSignal,
|
|
2743
2936
|
timeoutMs: config.upstreamTimeoutMs
|
|
2744
2937
|
});
|
|
2745
|
-
|
|
2746
|
-
|
|
2938
|
+
let decidedResponse;
|
|
2939
|
+
let bufferedChunks = [];
|
|
2940
|
+
let remainingStream;
|
|
2941
|
+
const streamState = {
|
|
2942
|
+
messageStartSent: false,
|
|
2943
|
+
contentBlockIndex: 0,
|
|
2944
|
+
contentBlockOpen: false,
|
|
2945
|
+
thinkingBlockOpen: false,
|
|
2946
|
+
toolCalls: {}
|
|
2947
|
+
};
|
|
2948
|
+
let streamedBeforeDecision = false;
|
|
2949
|
+
if (canStreamWebSearchDecision && !isNonStreamingResponse(response)) {
|
|
2950
|
+
const { decision, rest, alreadyStreamed } = await resolveWebSearchStreamDecision(response, toolNameMapper, isClaudeWebSearchToolName, async (chunk) => {
|
|
2951
|
+
await writeClaudeStreamEvents(translateChunkToClaudeEvents(chunk, streamState, toolNameMapper), writeEvent);
|
|
2952
|
+
});
|
|
2953
|
+
streamedBeforeDecision = alreadyStreamed;
|
|
2954
|
+
if (decision.kind === "webSearch") decidedResponse = decision.response;
|
|
2955
|
+
else {
|
|
2956
|
+
bufferedChunks = decision.buffered;
|
|
2957
|
+
remainingStream = rest;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
const effectiveResponse = decidedResponse ?? response;
|
|
2961
|
+
if (isNonStreamingResponse(effectiveResponse)) {
|
|
2962
|
+
const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(effectiveResponse, toolNameMapper) : void 0;
|
|
2747
2963
|
let claudeResponse;
|
|
2748
2964
|
if (webSearchToolCall) {
|
|
2749
2965
|
const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
|
|
@@ -2769,14 +2985,14 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2769
2985
|
}));
|
|
2770
2986
|
claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
|
|
2771
2987
|
}
|
|
2772
|
-
} else claudeResponse = translateToClaude(
|
|
2988
|
+
} else claudeResponse = translateToClaude(effectiveResponse, toolNameMapper);
|
|
2773
2989
|
if (writeEvent) {
|
|
2774
|
-
await writeClaudeStreamEvents(eventsFromClaudeResponse(claudeResponse), writeEvent);
|
|
2990
|
+
await writeClaudeStreamEvents(streamedBeforeDecision ? continuationEventsFromClaudeResponse(claudeResponse, streamState) : eventsFromClaudeResponse(claudeResponse), writeEvent);
|
|
2775
2991
|
return;
|
|
2776
2992
|
}
|
|
2777
2993
|
return claudeResponse;
|
|
2778
2994
|
}
|
|
2779
|
-
if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
|
|
2995
|
+
if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
|
|
2780
2996
|
status: 502,
|
|
2781
2997
|
headers: { "content-type": "text/plain" }
|
|
2782
2998
|
}));
|
|
@@ -2784,14 +3000,9 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2784
3000
|
status: 502,
|
|
2785
3001
|
headers: { "content-type": "text/plain" }
|
|
2786
3002
|
}));
|
|
2787
|
-
const streamState
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
contentBlockOpen: false,
|
|
2791
|
-
thinkingBlockOpen: false,
|
|
2792
|
-
toolCalls: {}
|
|
2793
|
-
};
|
|
2794
|
-
for await (const rawEvent of response) {
|
|
3003
|
+
for (const chunk of bufferedChunks) await writeClaudeStreamEvents(translateChunkToClaudeEvents(chunk, streamState, toolNameMapper), writeEvent);
|
|
3004
|
+
const upstreamStream = remainingStream ?? effectiveResponse;
|
|
3005
|
+
for await (const rawEvent of upstreamStream) {
|
|
2795
3006
|
if (rawEvent.data === "[DONE]") break;
|
|
2796
3007
|
if (!rawEvent.data) continue;
|
|
2797
3008
|
let chunk;
|
|
@@ -2933,7 +3144,10 @@ const createServer = (config) => {
|
|
|
2933
3144
|
name: "copilot-relay",
|
|
2934
3145
|
status: "ok"
|
|
2935
3146
|
}));
|
|
2936
|
-
app.get("/healthz", (c) => c.json({
|
|
3147
|
+
app.get("/healthz", (c) => c.json({
|
|
3148
|
+
ok: true,
|
|
3149
|
+
version: appVersion
|
|
3150
|
+
}));
|
|
2937
3151
|
app.on(["GET", "HEAD"], "/api/hello", (c) => c.body(null, 200));
|
|
2938
3152
|
app.route("/v1", claudeRoutes);
|
|
2939
3153
|
app.notFound(async (c) => {
|
|
@@ -3107,12 +3321,26 @@ const exitCodes = {
|
|
|
3107
3321
|
const localProbeTimeoutMs = 5e3;
|
|
3108
3322
|
const deepProbeTimeoutMs = 6e4;
|
|
3109
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
|
+
/**
|
|
3110
3333
|
* Status to exit code. Pure, because this mapping is the contract every
|
|
3111
3334
|
* scripted caller depends on and deserves direct coverage.
|
|
3112
3335
|
*
|
|
3113
3336
|
* 0 requires a live process *and* a passing health probe. Printing FAILED while
|
|
3114
3337
|
* exiting 0 would make every scripted caller treat an unreachable relay as
|
|
3115
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.
|
|
3116
3344
|
*/
|
|
3117
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;
|
|
3118
3346
|
const formatUptime = (startedAt) => {
|
|
@@ -3135,7 +3363,9 @@ const renderStatus = (status$1) => {
|
|
|
3135
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");
|
|
3136
3364
|
return lines;
|
|
3137
3365
|
}
|
|
3138
|
-
lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})
|
|
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}`);
|
|
3139
3369
|
const health = status$1.health;
|
|
3140
3370
|
lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
|
|
3141
3371
|
lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
|
|
@@ -3143,6 +3373,7 @@ const renderStatus = (status$1) => {
|
|
|
3143
3373
|
else lines.push(" upstream not checked (use --deep)");
|
|
3144
3374
|
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath} (logLevel=${status$1.logLevel}, thinkEffort=${status$1.thinkEffort})`);
|
|
3145
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");
|
|
3146
3377
|
return lines;
|
|
3147
3378
|
};
|
|
3148
3379
|
const probe = async (url, init, timeoutMs) => {
|
|
@@ -3159,22 +3390,34 @@ const probe = async (url, init, timeoutMs) => {
|
|
|
3159
3390
|
};
|
|
3160
3391
|
};
|
|
3161
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
|
+
*/
|
|
3162
3401
|
const checkHealth = async (baseUrl) => {
|
|
3163
3402
|
try {
|
|
3164
|
-
const
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
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 } : {}
|
|
3172
3415
|
};
|
|
3173
3416
|
} catch (error) {
|
|
3174
|
-
return {
|
|
3417
|
+
return { result: {
|
|
3175
3418
|
detail: describeError(error),
|
|
3176
3419
|
ok: false
|
|
3177
|
-
};
|
|
3420
|
+
} };
|
|
3178
3421
|
}
|
|
3179
3422
|
};
|
|
3180
3423
|
const readModels = async (baseUrl) => {
|
|
@@ -3249,13 +3492,15 @@ const collectStatus = async (options) => {
|
|
|
3249
3492
|
};
|
|
3250
3493
|
const baseUrl = `http://${relay.host}:${relay.port}`;
|
|
3251
3494
|
const health = await checkHealth(baseUrl);
|
|
3252
|
-
const models = health.ok ? await readModels(baseUrl) : [];
|
|
3253
|
-
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;
|
|
3254
3498
|
return {
|
|
3255
3499
|
...base,
|
|
3256
3500
|
baseUrl,
|
|
3501
|
+
...daemonVersion ? { daemonVersion } : {},
|
|
3257
3502
|
...deep ? { deep } : {},
|
|
3258
|
-
health,
|
|
3503
|
+
health: health.result,
|
|
3259
3504
|
models,
|
|
3260
3505
|
pid: relay.pid,
|
|
3261
3506
|
port: relay.port,
|