copilot-relay 0.2.7 → 0.3.0
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 +10 -3
- package/dist/main.js +223 -14
- 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
|
|
package/dist/main.js
CHANGED
|
@@ -2488,6 +2488,137 @@ const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
|
|
|
2488
2488
|
}
|
|
2489
2489
|
});
|
|
2490
2490
|
|
|
2491
|
+
//#endregion
|
|
2492
|
+
//#region src/claude/web-search-stream.ts
|
|
2493
|
+
const isWebSearchName = (name, toolNameMapper, isWebSearchToolName) => !!name && isWebSearchToolName(toolNameMapper.toClaude(name));
|
|
2494
|
+
const chunkSettlesDecision = (chunk) => {
|
|
2495
|
+
const choice = chunk.choices[0];
|
|
2496
|
+
if (!choice) return false;
|
|
2497
|
+
return choice.delta?.tool_calls?.some((call) => call.function?.name) === true || !!choice.finish_reason;
|
|
2498
|
+
};
|
|
2499
|
+
const chunkHasWebSearchCall = (chunk, toolNameMapper, isWebSearchToolName) => chunk.choices[0]?.delta?.tool_calls?.some((call) => isWebSearchName(call.function?.name, toolNameMapper, isWebSearchToolName)) ?? false;
|
|
2500
|
+
const accumulateChunks = (chunks) => {
|
|
2501
|
+
const first = chunks[0];
|
|
2502
|
+
const toolCalls = [];
|
|
2503
|
+
let content = "";
|
|
2504
|
+
let reasoning = "";
|
|
2505
|
+
let finishReason = null;
|
|
2506
|
+
let usage;
|
|
2507
|
+
for (const chunk of chunks) {
|
|
2508
|
+
if (chunk.usage) usage = chunk.usage;
|
|
2509
|
+
const choice = chunk.choices[0];
|
|
2510
|
+
if (!choice) continue;
|
|
2511
|
+
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
2512
|
+
const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
|
|
2513
|
+
if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
|
|
2514
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
2515
|
+
for (const call of choice.delta?.tool_calls ?? []) {
|
|
2516
|
+
const existing = toolCalls[call.index];
|
|
2517
|
+
if (existing) {
|
|
2518
|
+
existing.function.arguments += call.function?.arguments ?? "";
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
toolCalls[call.index] = {
|
|
2522
|
+
id: call.id ?? `call_${call.index}`,
|
|
2523
|
+
type: "function",
|
|
2524
|
+
function: {
|
|
2525
|
+
name: call.function?.name ?? "",
|
|
2526
|
+
arguments: call.function?.arguments ?? ""
|
|
2527
|
+
}
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
const collectedToolCalls = toolCalls.filter(Boolean);
|
|
2532
|
+
return {
|
|
2533
|
+
id: first?.id ?? "chat_stream_accumulated",
|
|
2534
|
+
object: "chat.completion",
|
|
2535
|
+
created: first?.created ?? 0,
|
|
2536
|
+
model: first?.model ?? "",
|
|
2537
|
+
choices: [{
|
|
2538
|
+
index: 0,
|
|
2539
|
+
message: {
|
|
2540
|
+
role: "assistant",
|
|
2541
|
+
content: content || null,
|
|
2542
|
+
...reasoning ? { reasoning_text: reasoning } : {},
|
|
2543
|
+
...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
|
|
2544
|
+
},
|
|
2545
|
+
finish_reason: finishReason ?? "stop"
|
|
2546
|
+
}],
|
|
2547
|
+
...usage ? { usage } : {}
|
|
2548
|
+
};
|
|
2549
|
+
};
|
|
2550
|
+
const resolveWebSearchStreamDecision = async (stream, toolNameMapper, isWebSearchToolName, onChunk) => {
|
|
2551
|
+
const buffered = [];
|
|
2552
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
2553
|
+
let sawWebSearch = false;
|
|
2554
|
+
let settled = false;
|
|
2555
|
+
let done = false;
|
|
2556
|
+
let alreadyStreamed = false;
|
|
2557
|
+
while (!settled) {
|
|
2558
|
+
const next = await iterator.next();
|
|
2559
|
+
if (next.done) {
|
|
2560
|
+
done = true;
|
|
2561
|
+
break;
|
|
2562
|
+
}
|
|
2563
|
+
const raw = next.value;
|
|
2564
|
+
if (raw.data === "[DONE]") {
|
|
2565
|
+
done = true;
|
|
2566
|
+
break;
|
|
2567
|
+
}
|
|
2568
|
+
if (!raw.data) continue;
|
|
2569
|
+
let chunk;
|
|
2570
|
+
try {
|
|
2571
|
+
chunk = JSON.parse(raw.data);
|
|
2572
|
+
} catch {
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
buffered.push(chunk);
|
|
2576
|
+
if (chunkHasWebSearchCall(chunk, toolNameMapper, isWebSearchToolName)) {
|
|
2577
|
+
sawWebSearch = true;
|
|
2578
|
+
settled = true;
|
|
2579
|
+
break;
|
|
2580
|
+
}
|
|
2581
|
+
if (onChunk) {
|
|
2582
|
+
await onChunk(chunk);
|
|
2583
|
+
alreadyStreamed = true;
|
|
2584
|
+
}
|
|
2585
|
+
if (chunkSettlesDecision(chunk)) settled = true;
|
|
2586
|
+
}
|
|
2587
|
+
const rest = { async *[Symbol.asyncIterator]() {
|
|
2588
|
+
if (done) return;
|
|
2589
|
+
while (true) {
|
|
2590
|
+
const next = await iterator.next();
|
|
2591
|
+
if (next.done) return;
|
|
2592
|
+
yield next.value;
|
|
2593
|
+
}
|
|
2594
|
+
} };
|
|
2595
|
+
if (!sawWebSearch) return {
|
|
2596
|
+
decision: {
|
|
2597
|
+
kind: "streamed",
|
|
2598
|
+
buffered: onChunk ? [] : buffered
|
|
2599
|
+
},
|
|
2600
|
+
rest,
|
|
2601
|
+
alreadyStreamed
|
|
2602
|
+
};
|
|
2603
|
+
for await (const raw of rest) {
|
|
2604
|
+
if (raw.data === "[DONE]") break;
|
|
2605
|
+
if (!raw.data) continue;
|
|
2606
|
+
try {
|
|
2607
|
+
buffered.push(JSON.parse(raw.data));
|
|
2608
|
+
} catch {
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
return {
|
|
2613
|
+
decision: {
|
|
2614
|
+
kind: "webSearch",
|
|
2615
|
+
response: accumulateChunks(buffered)
|
|
2616
|
+
},
|
|
2617
|
+
rest: { async *[Symbol.asyncIterator]() {} },
|
|
2618
|
+
alreadyStreamed
|
|
2619
|
+
};
|
|
2620
|
+
};
|
|
2621
|
+
|
|
2491
2622
|
//#endregion
|
|
2492
2623
|
//#region src/lib/tokenizer.ts
|
|
2493
2624
|
const encodingMap = {
|
|
@@ -2716,6 +2847,65 @@ const eventsFromClaudeResponse = (response) => {
|
|
|
2716
2847
|
events$1.push({ type: "message_stop" });
|
|
2717
2848
|
return events$1;
|
|
2718
2849
|
};
|
|
2850
|
+
const continuationEventsFromClaudeResponse = (response, state) => {
|
|
2851
|
+
const events$1 = [];
|
|
2852
|
+
if (state.contentBlockOpen) {
|
|
2853
|
+
events$1.push({
|
|
2854
|
+
type: "content_block_stop",
|
|
2855
|
+
index: state.contentBlockIndex
|
|
2856
|
+
});
|
|
2857
|
+
state.contentBlockIndex += 1;
|
|
2858
|
+
state.contentBlockOpen = false;
|
|
2859
|
+
state.thinkingBlockOpen = false;
|
|
2860
|
+
}
|
|
2861
|
+
for (const block of response.content) {
|
|
2862
|
+
const index = state.contentBlockIndex;
|
|
2863
|
+
events$1.push({
|
|
2864
|
+
type: "content_block_start",
|
|
2865
|
+
index,
|
|
2866
|
+
content_block: block
|
|
2867
|
+
});
|
|
2868
|
+
if (block.type === "text") events$1.push({
|
|
2869
|
+
type: "content_block_delta",
|
|
2870
|
+
index,
|
|
2871
|
+
delta: {
|
|
2872
|
+
type: "text_delta",
|
|
2873
|
+
text: block.text
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
else if (block.type === "thinking") events$1.push({
|
|
2877
|
+
type: "content_block_delta",
|
|
2878
|
+
index,
|
|
2879
|
+
delta: {
|
|
2880
|
+
type: "thinking_delta",
|
|
2881
|
+
thinking: block.thinking
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
else if (block.type === "tool_use" || block.type === "server_tool_use") events$1.push({
|
|
2885
|
+
type: "content_block_delta",
|
|
2886
|
+
index,
|
|
2887
|
+
delta: {
|
|
2888
|
+
type: "input_json_delta",
|
|
2889
|
+
partial_json: JSON.stringify(block.input)
|
|
2890
|
+
}
|
|
2891
|
+
});
|
|
2892
|
+
events$1.push({
|
|
2893
|
+
type: "content_block_stop",
|
|
2894
|
+
index
|
|
2895
|
+
});
|
|
2896
|
+
state.contentBlockIndex += 1;
|
|
2897
|
+
}
|
|
2898
|
+
events$1.push({
|
|
2899
|
+
type: "message_delta",
|
|
2900
|
+
delta: {
|
|
2901
|
+
stop_reason: response.stop_reason,
|
|
2902
|
+
stop_sequence: response.stop_sequence
|
|
2903
|
+
},
|
|
2904
|
+
usage: { output_tokens: response.usage.output_tokens }
|
|
2905
|
+
});
|
|
2906
|
+
events$1.push({ type: "message_stop" });
|
|
2907
|
+
return events$1;
|
|
2908
|
+
};
|
|
2719
2909
|
const createQueuedClaudeStreamWriter = (write) => {
|
|
2720
2910
|
let pending = Promise.resolve();
|
|
2721
2911
|
return (event) => {
|
|
@@ -2732,7 +2922,8 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2732
2922
|
const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
|
|
2733
2923
|
const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
|
|
2734
2924
|
const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
|
|
2735
|
-
|
|
2925
|
+
const canStreamWebSearchDecision = shouldLetModelDecideWebSearch && !!writeEvent;
|
|
2926
|
+
if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
|
|
2736
2927
|
const response = await createChatCompletions(config, openAIPayload, {
|
|
2737
2928
|
client: "claude",
|
|
2738
2929
|
requestedModel: claudePayload.model,
|
|
@@ -2742,8 +2933,31 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2742
2933
|
signal: requestSignal,
|
|
2743
2934
|
timeoutMs: config.upstreamTimeoutMs
|
|
2744
2935
|
});
|
|
2745
|
-
|
|
2746
|
-
|
|
2936
|
+
let decidedResponse;
|
|
2937
|
+
let bufferedChunks = [];
|
|
2938
|
+
let remainingStream;
|
|
2939
|
+
const streamState = {
|
|
2940
|
+
messageStartSent: false,
|
|
2941
|
+
contentBlockIndex: 0,
|
|
2942
|
+
contentBlockOpen: false,
|
|
2943
|
+
thinkingBlockOpen: false,
|
|
2944
|
+
toolCalls: {}
|
|
2945
|
+
};
|
|
2946
|
+
let streamedBeforeDecision = false;
|
|
2947
|
+
if (canStreamWebSearchDecision && !isNonStreamingResponse(response)) {
|
|
2948
|
+
const { decision, rest, alreadyStreamed } = await resolveWebSearchStreamDecision(response, toolNameMapper, isClaudeWebSearchToolName, async (chunk) => {
|
|
2949
|
+
await writeClaudeStreamEvents(translateChunkToClaudeEvents(chunk, streamState, toolNameMapper), writeEvent);
|
|
2950
|
+
});
|
|
2951
|
+
streamedBeforeDecision = alreadyStreamed;
|
|
2952
|
+
if (decision.kind === "webSearch") decidedResponse = decision.response;
|
|
2953
|
+
else {
|
|
2954
|
+
bufferedChunks = decision.buffered;
|
|
2955
|
+
remainingStream = rest;
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
const effectiveResponse = decidedResponse ?? response;
|
|
2959
|
+
if (isNonStreamingResponse(effectiveResponse)) {
|
|
2960
|
+
const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(effectiveResponse, toolNameMapper) : void 0;
|
|
2747
2961
|
let claudeResponse;
|
|
2748
2962
|
if (webSearchToolCall) {
|
|
2749
2963
|
const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
|
|
@@ -2769,14 +2983,14 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2769
2983
|
}));
|
|
2770
2984
|
claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
|
|
2771
2985
|
}
|
|
2772
|
-
} else claudeResponse = translateToClaude(
|
|
2986
|
+
} else claudeResponse = translateToClaude(effectiveResponse, toolNameMapper);
|
|
2773
2987
|
if (writeEvent) {
|
|
2774
|
-
await writeClaudeStreamEvents(eventsFromClaudeResponse(claudeResponse), writeEvent);
|
|
2988
|
+
await writeClaudeStreamEvents(streamedBeforeDecision ? continuationEventsFromClaudeResponse(claudeResponse, streamState) : eventsFromClaudeResponse(claudeResponse), writeEvent);
|
|
2775
2989
|
return;
|
|
2776
2990
|
}
|
|
2777
2991
|
return claudeResponse;
|
|
2778
2992
|
}
|
|
2779
|
-
if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
|
|
2993
|
+
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
2994
|
status: 502,
|
|
2781
2995
|
headers: { "content-type": "text/plain" }
|
|
2782
2996
|
}));
|
|
@@ -2784,14 +2998,9 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
2784
2998
|
status: 502,
|
|
2785
2999
|
headers: { "content-type": "text/plain" }
|
|
2786
3000
|
}));
|
|
2787
|
-
const streamState
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
contentBlockOpen: false,
|
|
2791
|
-
thinkingBlockOpen: false,
|
|
2792
|
-
toolCalls: {}
|
|
2793
|
-
};
|
|
2794
|
-
for await (const rawEvent of response) {
|
|
3001
|
+
for (const chunk of bufferedChunks) await writeClaudeStreamEvents(translateChunkToClaudeEvents(chunk, streamState, toolNameMapper), writeEvent);
|
|
3002
|
+
const upstreamStream = remainingStream ?? effectiveResponse;
|
|
3003
|
+
for await (const rawEvent of upstreamStream) {
|
|
2795
3004
|
if (rawEvent.data === "[DONE]") break;
|
|
2796
3005
|
if (!rawEvent.data) continue;
|
|
2797
3006
|
let chunk;
|