copilot-relay 0.2.6 → 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
@@ -24,13 +24,25 @@ 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
30
35
  retrieved context through a final model pass and returns Claude
31
- `server_tool_use` / `web_search_tool_result` blocks. Unknown API routes return
32
- `500` and log method, path, selected headers, and request payload to help
33
- 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.
34
46
 
35
47
  Routing:
36
48
 
package/dist/main.js CHANGED
@@ -1508,6 +1508,7 @@ function getFinishReason(response, hasFunctionCalls) {
1508
1508
  //#region src/copilot/chat.ts
1509
1509
  const usesMaxCompletionTokens = (modelId) => modelId.startsWith("gpt-5");
1510
1510
  const continuePrefillPrompt$1 = "Continue the assistant response from the previous assistant message.";
1511
+ const continueAfterNonUserPrompt = "Continue based on the context above.";
1511
1512
  const maxUserLength = 64;
1512
1513
  const sanitizeReasoningEffortForModel = (_modelId, reasoningEffort) => {
1513
1514
  return reasoningEffort ?? void 0;
@@ -1536,7 +1537,14 @@ const buildRequestPayload = (payload) => {
1536
1537
  };
1537
1538
  const normalizeFinalAssistantPrefill$1 = (payload) => {
1538
1539
  const lastMessage = payload.messages.at(-1);
1539
- if (lastMessage?.role !== "assistant") return payload;
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
+ };
1540
1548
  const messages = [...payload.messages];
1541
1549
  if (typeof lastMessage.content !== "string") {
1542
1550
  messages.pop();
@@ -2435,10 +2443,10 @@ const getWebSearchResultText = (search) => {
2435
2443
  ].join("\n");
2436
2444
  };
2437
2445
  const createWebSearchResultContextMessage = (search) => ({
2438
- role: "system",
2446
+ role: "user",
2439
2447
  content: [
2440
2448
  "Trusted bridge retrieval context: the assistant selected web_search, and copilot-relay executed it.",
2441
- "Use this context for the final answer. Do not describe it as user-provided or injected. Do not call web_search again.",
2449
+ "Use this context to complete the request. Do not describe it as user-provided or injected.",
2442
2450
  "If the user requested a specific output format, answer using only matching information from this context.",
2443
2451
  "If the user asked for a URL only, output only that URL with no surrounding text.",
2444
2452
  "",
@@ -2447,24 +2455,26 @@ const createWebSearchResultContextMessage = (search) => ({
2447
2455
  getWebSearchResultText(search)
2448
2456
  ].join("\n")
2449
2457
  });
2450
- const createFinalWebSearchPayload = (payload, search) => {
2451
- const messages = payload.messages.flatMap((message) => {
2452
- if (message.role === "tool") return [{
2453
- role: "developer",
2454
- content: ["Prior local tool result from the conversation:", String(message.content ?? "")].join("\n")
2455
- }];
2456
- if (message.tool_calls && message.tool_calls.length > 0) return message.content ? [{
2457
- ...message,
2458
- tool_calls: void 0
2459
- }] : [];
2460
- return [message];
2461
- });
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);
2462
2472
  return {
2463
2473
  ...payload,
2464
2474
  stream: false,
2465
- tools: void 0,
2466
- tool_choice: void 0,
2467
- messages: [...messages, createWebSearchResultContextMessage(search)]
2475
+ tools,
2476
+ tool_choice: normalizeFinalToolChoice(payload.tool_choice, tools, toolNameMapper),
2477
+ messages: [...payload.messages, createWebSearchResultContextMessage(search)]
2468
2478
  };
2469
2479
  };
2470
2480
  const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
@@ -2478,6 +2488,137 @@ const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
2478
2488
  }
2479
2489
  });
2480
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
+
2481
2622
  //#endregion
2482
2623
  //#region src/lib/tokenizer.ts
2483
2624
  const encodingMap = {
@@ -2706,6 +2847,65 @@ const eventsFromClaudeResponse = (response) => {
2706
2847
  events$1.push({ type: "message_stop" });
2707
2848
  return events$1;
2708
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
+ };
2709
2909
  const createQueuedClaudeStreamWriter = (write) => {
2710
2910
  let pending = Promise.resolve();
2711
2911
  return (event) => {
@@ -2722,7 +2922,8 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
2722
2922
  const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2723
2923
  const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2724
2924
  const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2725
- if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2925
+ const canStreamWebSearchDecision = shouldLetModelDecideWebSearch && !!writeEvent;
2926
+ if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
2726
2927
  const response = await createChatCompletions(config, openAIPayload, {
2727
2928
  client: "claude",
2728
2929
  requestedModel: claudePayload.model,
@@ -2732,8 +2933,31 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
2732
2933
  signal: requestSignal,
2733
2934
  timeoutMs: config.upstreamTimeoutMs
2734
2935
  });
2735
- if (isNonStreamingResponse(response)) {
2736
- 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;
2737
2961
  let claudeResponse;
2738
2962
  if (webSearchToolCall) {
2739
2963
  const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
@@ -2744,7 +2968,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
2744
2968
  const searchResponse = createClaudeWebSearchResponse(search);
2745
2969
  if (search.results.length === 0) claudeResponse = searchResponse;
2746
2970
  else {
2747
- const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2971
+ const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search, toolNameMapper), {
2748
2972
  client: "claude",
2749
2973
  requestedModel: claudePayload.model,
2750
2974
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
@@ -2759,14 +2983,14 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
2759
2983
  }));
2760
2984
  claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2761
2985
  }
2762
- } else claudeResponse = translateToClaude(response, toolNameMapper);
2986
+ } else claudeResponse = translateToClaude(effectiveResponse, toolNameMapper);
2763
2987
  if (writeEvent) {
2764
- await writeClaudeStreamEvents(eventsFromClaudeResponse(claudeResponse), writeEvent);
2988
+ await writeClaudeStreamEvents(streamedBeforeDecision ? continuationEventsFromClaudeResponse(claudeResponse, streamState) : eventsFromClaudeResponse(claudeResponse), writeEvent);
2765
2989
  return;
2766
2990
  }
2767
2991
  return claudeResponse;
2768
2992
  }
2769
- 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", {
2770
2994
  status: 502,
2771
2995
  headers: { "content-type": "text/plain" }
2772
2996
  }));
@@ -2774,14 +2998,9 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
2774
2998
  status: 502,
2775
2999
  headers: { "content-type": "text/plain" }
2776
3000
  }));
2777
- const streamState = {
2778
- messageStartSent: false,
2779
- contentBlockIndex: 0,
2780
- contentBlockOpen: false,
2781
- thinkingBlockOpen: false,
2782
- toolCalls: {}
2783
- };
2784
- 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) {
2785
3004
  if (rawEvent.data === "[DONE]") break;
2786
3005
  if (!rawEvent.data) continue;
2787
3006
  let chunk;
@@ -2924,6 +3143,7 @@ const createServer = (config) => {
2924
3143
  status: "ok"
2925
3144
  }));
2926
3145
  app.get("/healthz", (c) => c.json({ ok: true }));
3146
+ app.on(["GET", "HEAD"], "/api/hello", (c) => c.body(null, 200));
2927
3147
  app.route("/v1", claudeRoutes);
2928
3148
  app.notFound(async (c) => {
2929
3149
  const message = "Unsupported Claude API route";