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 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. Unknown API routes return
37
- `500` and log method, path, selected headers, and request payload to help
38
- implement compatible endpoints later.
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
- if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
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
- if (isNonStreamingResponse(response)) {
2746
- const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
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(response, toolNameMapper);
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
- messageStartSent: false,
2789
- contentBlockIndex: 0,
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;