copilot-relay 0.0.4 → 0.0.6

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
@@ -11,6 +11,13 @@
11
11
 
12
12
  Yet, just another relay for Claude Code to use a GitHub Copilot subscription.
13
13
 
14
+ ## Disclaimer
15
+
16
+ This is a research-oriented project and is not affiliated with GitHub, GitHub
17
+ Copilot, Anthropic, or Claude Code. It depends on upstream GitHub Copilot
18
+ services and undocumented compatibility behavior, so Copilot availability, model
19
+ access, API behavior, and runtime stability are not guaranteed.
20
+
14
21
  Public API:
15
22
 
16
23
  - `POST /v1/messages`
@@ -18,6 +25,13 @@ Public API:
18
25
  - `GET /v1/models`
19
26
  - `GET /healthz`
20
27
 
28
+ Claude WebSearch is bridge-managed: when the model selects the WebSearch tool,
29
+ the relay executes Copilot `/responses` with `web_search_preview`, then sends the
30
+ 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.
34
+
21
35
  Routing:
22
36
 
23
37
  | Requested model | Upstream model |
@@ -46,6 +60,7 @@ claudeSetup: true
46
60
  logLevel: info
47
61
  logRetentionDays: 3
48
62
  thinkEffort: xhigh
63
+ webSearchBackend:
49
64
  ```
50
65
 
51
66
  `logLevel` controls verbosity:
@@ -54,12 +69,15 @@ thinkEffort: xhigh
54
69
  | --- | --- |
55
70
  | `error` | Startup, preflight, and request failures |
56
71
  | `info` | Errors plus startup status, preflight status, and local HTTP status codes |
57
- | `debug` | Info plus model routing summaries, Copilot upstream timings, token refresh scheduling, and request payloads |
72
+ | `debug` | Info plus model routing summaries, Copilot upstream timings, and request payloads |
58
73
 
59
74
  Any other `logLevel` value is invalid and stops startup.
60
75
 
61
76
  Valid `thinkEffort`: `none`, `low`, `medium`, `high`, `xhigh`.
62
77
 
78
+ `webSearchBackend` controls bridge-managed Claude WebSearch. Leave it empty to
79
+ use `gptModel`, or set a Copilot Responses model ID such as `gpt-5.5`.
80
+
63
81
  The same folder stores `copilot_token.json` for the cached Copilot bearer token, `github_token` for refresh/login, and `logs/` for runtime logs.
64
82
 
65
83
  ## CLI
@@ -75,13 +93,26 @@ At `debug`, every model request logs the requested model, upstream model, reques
75
93
 
76
94
  Upstream failures are logged at `error` with full request and response context in the same log file.
77
95
 
96
+ Unsupported Claude API requests are logged at `error` with the local method/path
97
+ and detailed request payload.
98
+
78
99
  Logs are written to `~/.copilot-relay/logs/copilot-relay.log`; old `.log` files are cleaned according to `logRetentionDays`.
79
100
 
101
+ Quick inspection:
102
+
103
+ ```sh
104
+ tail -f ~/.copilot-relay/logs/copilot-relay.log
105
+ grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay.log
106
+ ```
107
+
108
+ See [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
109
+
80
110
  ## Development
81
111
 
82
112
  Developer notes live in [`docs/development.md`](docs/development.md).
83
113
  Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
84
114
  Logging notes live in [`docs/logging.md`](docs/logging.md).
115
+ Troubleshooting notes live in [`docs/troubleshooting.md`](docs/troubleshooting.md).
85
116
 
86
117
  ```sh
87
118
  npm install
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # This file is copied to ~/.copilot-relay/config.yaml on first start.
4
4
  # Edit the user config file, not this template, for local changes.
5
+ # Most values hot-reload while the service is running. host and port still
6
+ # require restart because the listening socket is already bound.
5
7
 
6
8
  # Local host for the Claude Code-compatible HTTP server.
7
9
  host: 127.0.0.1
@@ -22,7 +24,7 @@ claudeSetup: true
22
24
  # info - error logs plus startup status, preflight status, and local HTTP
23
25
  # status codes
24
26
  # debug - info logs plus model routing summaries, Copilot upstream timings,
25
- # token refresh scheduling, and request payloads
27
+ # and request payloads
26
28
  # Any other value is invalid and stops startup.
27
29
  logLevel: info
28
30
 
@@ -33,6 +35,10 @@ logRetentionDays: 3
33
35
  # Valid values: none, low, medium, high, xhigh.
34
36
  thinkEffort: xhigh
35
37
 
38
+ # Copilot model used for bridge-managed Claude WebSearch.
39
+ # Leave empty to use gptModel.
40
+ webSearchBackend:
41
+
36
42
  # Model routing: requests containing "opus" use opusModel; all others use gptModel.
37
43
  #
38
44
  # Upstream Copilot model used for non-Opus requests.
package/dist/main.js CHANGED
@@ -371,7 +371,8 @@ const defaultConfig = {
371
371
  logRetentionDays: 3,
372
372
  opusModel: "claude-opus-4.8",
373
373
  port: 4142,
374
- thinkEffort: defaultReasoningEffort
374
+ thinkEffort: defaultReasoningEffort,
375
+ webSearchBackend: void 0
375
376
  };
376
377
  const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
377
378
  const normalizeLogLevel = (value) => {
@@ -475,6 +476,10 @@ const parseConfigYaml = (content) => {
475
476
  case "think_effort":
476
477
  config.thinkEffort = unquoteYamlScalar(value);
477
478
  break;
479
+ case "webSearchBackend":
480
+ case "web_search_backend":
481
+ config.webSearchBackend = unquoteYamlScalar(value);
482
+ break;
478
483
  default: break;
479
484
  }
480
485
  }
@@ -508,7 +513,7 @@ const serializeConfig = (config) => [
508
513
  "# info - error logs plus startup status, preflight status, and local HTTP",
509
514
  "# status codes",
510
515
  "# debug - info logs plus model routing summaries, Copilot upstream timings,",
511
- "# token refresh scheduling, request payloads, and upstream error details",
516
+ "# and request payloads",
512
517
  `logLevel: ${config.logLevel}`,
513
518
  "",
514
519
  "# Number of days to keep files in ~/.copilot-relay/logs.",
@@ -517,6 +522,9 @@ const serializeConfig = (config) => [
517
522
  "# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh.",
518
523
  `thinkEffort: ${config.thinkEffort}`,
519
524
  "",
525
+ "# Copilot model used for bridge-managed Claude WebSearch. Empty uses gptModel.",
526
+ `webSearchBackend: ${config.webSearchBackend ?? ""}`,
527
+ "",
520
528
  "# Model routing: requests containing \"opus\" use opusModel; all others use gptModel.",
521
529
  "",
522
530
  "# Upstream Copilot model used for non-Opus requests.",
@@ -543,7 +551,8 @@ async function readAppConfig() {
543
551
  logRetentionDays: logRetentionDays$1 ?? defaultConfig.logRetentionDays,
544
552
  opusModel: normalizeString(raw.opusModel) ?? defaultConfig.opusModel,
545
553
  port: port ?? defaultConfig.port,
546
- thinkEffort: thinkEffort ?? defaultConfig.thinkEffort
554
+ thinkEffort: thinkEffort ?? defaultConfig.thinkEffort,
555
+ webSearchBackend: normalizeString(raw.webSearchBackend)
547
556
  };
548
557
  await writeConfig(config);
549
558
  return config;
@@ -581,7 +590,8 @@ const readProxyConfig = (config) => ({
581
590
  copilotToken: void 0,
582
591
  host: config.host,
583
592
  port: config.port,
584
- vsCodeVersion: vscodeVersion
593
+ vsCodeVersion: vscodeVersion,
594
+ webSearchBackend: config.webSearchBackend
585
595
  });
586
596
 
587
597
  //#endregion
@@ -1724,6 +1734,256 @@ function translateErrorToClaudeErrorEvent() {
1724
1734
  };
1725
1735
  }
1726
1736
 
1737
+ //#endregion
1738
+ //#region src/claude/web-search.ts
1739
+ const anthropicWebSearchToolPattern = /^web_search_\d{8}$/;
1740
+ const claudeCodeWebSearchToolName = "WebSearch";
1741
+ const searchResultLimit = 8;
1742
+ const webSearchInputSchema = {
1743
+ type: "object",
1744
+ properties: { query: {
1745
+ type: "string",
1746
+ description: "The web search query."
1747
+ } },
1748
+ required: ["query"],
1749
+ additionalProperties: false
1750
+ };
1751
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1752
+ const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
1753
+ const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
1754
+ const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
1755
+ const hasClaudeWebSearch = (payload) => payload.tools?.some(isClaudeWebSearchTool) ?? false;
1756
+ const isClaudeWebSearchToolName = (name) => name === "web_search" || name === claudeCodeWebSearchToolName;
1757
+ const getWebSearchBackendModel = (config) => config.webSearchBackend?.trim() || getModelRouting().gptModel;
1758
+ const prepareClaudeWebSearchDecisionPayload = (payload) => {
1759
+ if (!hasClaudeWebSearch(payload)) return payload;
1760
+ return {
1761
+ ...payload,
1762
+ tools: payload.tools?.map((tool) => isClaudeWebSearchTool(tool) ? {
1763
+ ...tool,
1764
+ input_schema: tool.input_schema ?? webSearchInputSchema
1765
+ } : tool)
1766
+ };
1767
+ };
1768
+ const textFromMessageContent = (content) => {
1769
+ if (typeof content === "string") return content;
1770
+ return content.flatMap((block) => {
1771
+ if (block.type === "text") return [block.text];
1772
+ if (block.type === "tool_result") return [block.content];
1773
+ return [];
1774
+ }).join("\n\n");
1775
+ };
1776
+ const getRequestedQuery = (payload) => {
1777
+ const lastUserMessage = [...payload.messages].reverse().find((message) => message.role === "user");
1778
+ const cleaned = (lastUserMessage ? textFromMessageContent(lastUserMessage.content) : "").replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, " ").replace(/\s+/g, " ").trim();
1779
+ return cleaned.match(/\bsearch(?:\s+the\s+web)?(?:\s+for)?\s+(.+)$/i)?.[1]?.trim() || cleaned || "web search";
1780
+ };
1781
+ const getQueryFromToolArguments = (value) => {
1782
+ const trimmed = value.trim();
1783
+ if (!trimmed) return;
1784
+ try {
1785
+ const parsed = JSON.parse(trimmed);
1786
+ if (isRecord(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
1787
+ } catch {
1788
+ return trimmed;
1789
+ }
1790
+ return trimmed;
1791
+ };
1792
+ const getClaudeWebSearchToolCallFromChatResponse = (response, toolNameMapper) => {
1793
+ const toolCall = response.choices.flatMap((choice) => choice.message.tool_calls ?? []).find((call) => isClaudeWebSearchToolName(toolNameMapper.toClaude(call.function.name)));
1794
+ if (!toolCall) return;
1795
+ const query = getQueryFromToolArguments(toolCall.function.arguments);
1796
+ return query ? {
1797
+ query,
1798
+ toolCall
1799
+ } : void 0;
1800
+ };
1801
+ const buildSearchInput = (payload, requestedQuery) => {
1802
+ const systemText = typeof payload.system === "string" ? payload.system : Array.isArray(payload.system) ? payload.system.map((block) => block.text).join("\n\n") : "";
1803
+ const messages = payload.messages.map((message) => `${message.role}: ${textFromMessageContent(message.content)}`).join("\n\n");
1804
+ return [
1805
+ "You are fulfilling an Anthropic web_search server tool request for Claude Code.",
1806
+ "Search the web using the provided web_search_preview tool.",
1807
+ `Requested web search query: ${requestedQuery}`,
1808
+ "Return useful search results as plain text lines in this exact shape:",
1809
+ "1. Title - https://example.com/page",
1810
+ "Include only real source URLs from the search results.",
1811
+ systemText ? `System context:\n${systemText}` : "",
1812
+ `Conversation:\n${messages}`
1813
+ ].filter(Boolean).join("\n\n");
1814
+ };
1815
+ const buildWebSearchRequestPayload = (payload, requestedQuery, model) => ({
1816
+ model,
1817
+ input: buildSearchInput(payload, requestedQuery),
1818
+ tools: [{ type: "web_search_preview" }],
1819
+ max_output_tokens: Math.max(256, Math.min(payload.max_tokens ?? 1024, 1200)),
1820
+ temperature: payload.temperature,
1821
+ top_p: payload.top_p
1822
+ });
1823
+ const getSearchQuery = (response, requestedQuery) => {
1824
+ for (const item of response.output ?? []) {
1825
+ if (item.type !== "web_search_call") continue;
1826
+ const action = isRecord(item.action) ? item.action : void 0;
1827
+ const queries = Array.isArray(action?.queries) ? action.queries : [];
1828
+ const query = typeof action?.query === "string" ? action.query : typeof queries[0] === "string" ? queries[0] : void 0;
1829
+ if (query) return query;
1830
+ }
1831
+ return requestedQuery.slice(0, 200);
1832
+ };
1833
+ const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
1834
+ if (item.type !== "message") return [];
1835
+ return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
1836
+ }).join("\n").trim();
1837
+ const cleanTitle = (line, url) => {
1838
+ return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
1839
+ };
1840
+ const parseSearchResults = (text) => {
1841
+ const results = [];
1842
+ const seenUrls = /* @__PURE__ */ new Set();
1843
+ for (const line of text.split(/\r?\n/)) {
1844
+ const markdownMatch = line.match(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/);
1845
+ const url = markdownMatch?.[2] ?? line.match(/https?:\/\/[^\s)]+/)?.[0];
1846
+ if (!url || seenUrls.has(url)) continue;
1847
+ seenUrls.add(url);
1848
+ results.push({
1849
+ title: markdownMatch?.[1]?.trim() || cleanTitle(line, url),
1850
+ url
1851
+ });
1852
+ if (results.length >= searchResultLimit) break;
1853
+ }
1854
+ return results;
1855
+ };
1856
+ const createFailedSearchExecution = (payload, requestedQuery, model, message) => ({
1857
+ id: `msg_${randomUUID().replaceAll("-", "")}`,
1858
+ inputTokens: 0,
1859
+ model,
1860
+ outputTokens: 0,
1861
+ query: requestedQuery || getRequestedQuery(payload),
1862
+ results: [],
1863
+ text: message
1864
+ });
1865
+ const createClaudeWebSearchExecution = async (config, payload, requestedQuery) => {
1866
+ const backendModel = getWebSearchBackendModel(config);
1867
+ const response = await fetchCopilot(getCopilotProviderContext(config), "/responses", {
1868
+ method: "POST",
1869
+ headers: {
1870
+ accept: "application/json",
1871
+ "content-type": "application/json"
1872
+ },
1873
+ body: JSON.stringify(buildWebSearchRequestPayload(payload, requestedQuery, backendModel))
1874
+ }, { initiator: "agent" });
1875
+ if (!response.ok) {
1876
+ const detail = await response.text().catch(() => "");
1877
+ return createFailedSearchExecution(payload, requestedQuery, backendModel, [`Copilot web search is not available for model ${backendModel}.`, detail ? `Upstream response: ${detail}` : ""].filter(Boolean).join("\n"));
1878
+ }
1879
+ const upstream = await response.json();
1880
+ const text = getResponseText(upstream);
1881
+ const query = getSearchQuery(upstream, requestedQuery);
1882
+ const results = parseSearchResults(text);
1883
+ if (results.length === 0 && !text.trim()) return createFailedSearchExecution(payload, requestedQuery, upstream.model, "Copilot web search did not return search results.");
1884
+ return {
1885
+ id: upstream.id,
1886
+ inputTokens: upstream.usage?.input_tokens ?? 0,
1887
+ model: upstream.model,
1888
+ outputTokens: upstream.usage?.output_tokens ?? 0,
1889
+ query,
1890
+ results,
1891
+ text
1892
+ };
1893
+ };
1894
+ const buildSearchResultBlock = (toolUseId, results) => ({
1895
+ type: "web_search_tool_result",
1896
+ tool_use_id: toolUseId,
1897
+ content: results.length > 0 ? results.map((result) => ({
1898
+ type: "web_search_result",
1899
+ title: result.title,
1900
+ url: result.url,
1901
+ encrypted_content: "",
1902
+ page_age: null
1903
+ })) : {
1904
+ type: "web_search_tool_result_error",
1905
+ error_code: "unavailable"
1906
+ }
1907
+ });
1908
+ const createClaudeWebSearchResponse = (search) => {
1909
+ const toolUseId = `srvtoolu_${randomUUID().replaceAll("-", "")}`;
1910
+ const content = [{
1911
+ type: "server_tool_use",
1912
+ id: toolUseId,
1913
+ name: "web_search",
1914
+ input: { query: search.query }
1915
+ }, buildSearchResultBlock(toolUseId, search.results)];
1916
+ if (search.text) content.push({
1917
+ type: "text",
1918
+ text: search.text
1919
+ });
1920
+ return {
1921
+ id: search.id,
1922
+ type: "message",
1923
+ role: "assistant",
1924
+ content,
1925
+ model: search.model,
1926
+ stop_reason: "end_turn",
1927
+ stop_sequence: null,
1928
+ usage: {
1929
+ input_tokens: search.inputTokens,
1930
+ output_tokens: search.outputTokens,
1931
+ server_tool_use: { web_search_requests: 1 }
1932
+ }
1933
+ };
1934
+ };
1935
+ const getWebSearchResultText = (search) => {
1936
+ if (search.results.length === 0) return search.text || "Web search did not return search results.";
1937
+ return [
1938
+ `Web search results for query: "${search.query}"`,
1939
+ "",
1940
+ ...search.results.map((result, index) => `${index + 1}. ${result.title} - ${result.url}`)
1941
+ ].join("\n");
1942
+ };
1943
+ const createWebSearchResultContextMessage = (search) => ({
1944
+ role: "system",
1945
+ content: [
1946
+ "Trusted bridge retrieval context: the assistant selected web_search, and copilot-relay executed it.",
1947
+ "Use this context for the final answer. Do not describe it as user-provided or injected. Do not call web_search again.",
1948
+ "If the user requested a specific output format, answer using only matching information from this context.",
1949
+ "If the user asked for a URL only, output only that URL with no surrounding text.",
1950
+ "",
1951
+ `Query: ${search.query}`,
1952
+ "",
1953
+ getWebSearchResultText(search)
1954
+ ].join("\n")
1955
+ });
1956
+ const createFinalWebSearchPayload = (payload, search) => {
1957
+ const messages = payload.messages.flatMap((message) => {
1958
+ if (message.role === "tool") return [{
1959
+ role: "developer",
1960
+ content: ["Prior local tool result from the conversation:", String(message.content ?? "")].join("\n")
1961
+ }];
1962
+ if (message.tool_calls && message.tool_calls.length > 0) return message.content ? [{
1963
+ ...message,
1964
+ tool_calls: void 0
1965
+ }] : [];
1966
+ return [message];
1967
+ });
1968
+ return {
1969
+ ...payload,
1970
+ stream: false,
1971
+ tools: void 0,
1972
+ tool_choice: void 0,
1973
+ messages: [...messages, createWebSearchResultContextMessage(search)]
1974
+ };
1975
+ };
1976
+ const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
1977
+ ...finalResponse,
1978
+ content: [...searchResponse.content.slice(0, 2), ...finalResponse.content],
1979
+ usage: {
1980
+ ...finalResponse.usage,
1981
+ input_tokens: searchResponse.usage.input_tokens + finalResponse.usage.input_tokens,
1982
+ output_tokens: searchResponse.usage.output_tokens + finalResponse.usage.output_tokens,
1983
+ server_tool_use: { web_search_requests: 1 }
1984
+ }
1985
+ });
1986
+
1727
1987
  //#endregion
1728
1988
  //#region src/lib/tokenizer.ts
1729
1989
  const encodingMap = {
@@ -1928,7 +2188,7 @@ const eventsFromClaudeResponse = (response) => {
1928
2188
  thinking: block.thinking
1929
2189
  }
1930
2190
  });
1931
- else if (block.type === "tool_use") events$1.push({
2191
+ else if (block.type === "tool_use" || block.type === "server_tool_use") events$1.push({
1932
2192
  type: "content_block_delta",
1933
2193
  index,
1934
2194
  delta: {
@@ -1967,15 +2227,38 @@ claudeRoutes.post("/messages", async (c) => {
1967
2227
  log.debug("Full Claude request payload", { payload: claudePayload });
1968
2228
  try {
1969
2229
  const upstreamModel = translateModelName(claudePayload.model);
1970
- const toolNameMapper = createClaudeToolNameMapper(claudePayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
1971
- const response = await createChatCompletions(config, translateToOpenAI(claudePayload, void 0, toolNameMapper), {
2230
+ const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
2231
+ const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2232
+ const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2233
+ const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2234
+ if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2235
+ const response = await createChatCompletions(config, openAIPayload, {
1972
2236
  client: "claude",
1973
2237
  requestedModel: claudePayload.model,
1974
2238
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
1975
2239
  requestedThinking: getClaudeRequestedThinking(claudePayload)
1976
2240
  });
1977
2241
  if (isNonStreamingResponse(response)) {
1978
- const claudeResponse = translateToClaude(response, toolNameMapper);
2242
+ const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2243
+ let claudeResponse;
2244
+ if (webSearchToolCall) {
2245
+ const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query);
2246
+ const searchResponse = createClaudeWebSearchResponse(search);
2247
+ if (search.results.length === 0) claudeResponse = searchResponse;
2248
+ else {
2249
+ const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2250
+ client: "claude",
2251
+ requestedModel: claudePayload.model,
2252
+ requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2253
+ requestedThinking: getClaudeRequestedThinking(claudePayload)
2254
+ });
2255
+ if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2256
+ status: 502,
2257
+ headers: { "content-type": "text/plain" }
2258
+ }));
2259
+ claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2260
+ }
2261
+ } else claudeResponse = translateToClaude(response, toolNameMapper);
1979
2262
  if (!claudePayload.stream) return c.json(claudeResponse);
1980
2263
  return streamSSE(c, async (stream) => {
1981
2264
  for (const event of eventsFromClaudeResponse(claudeResponse)) await stream.writeSSE({
@@ -1984,6 +2267,10 @@ claudeRoutes.post("/messages", async (c) => {
1984
2267
  });
1985
2268
  });
1986
2269
  }
2270
+ if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
2271
+ status: 502,
2272
+ headers: { "content-type": "text/plain" }
2273
+ }));
1987
2274
  return streamSSE(c, async (stream) => {
1988
2275
  const streamState = {
1989
2276
  messageStartSent: false,
@@ -2060,6 +2347,25 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
2060
2347
 
2061
2348
  //#endregion
2062
2349
  //#region src/server.ts
2350
+ const loggedRequestHeaders = [
2351
+ "anthropic-beta",
2352
+ "anthropic-version",
2353
+ "claude-beta",
2354
+ "content-type"
2355
+ ];
2356
+ const readRequestPayloadForLog = async (request) => {
2357
+ const text = await request.clone().text().catch(() => "");
2358
+ if (!text) return;
2359
+ try {
2360
+ return JSON.parse(text);
2361
+ } catch {
2362
+ return text;
2363
+ }
2364
+ };
2365
+ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.flatMap((name) => {
2366
+ const value = request.headers.get(name);
2367
+ return value ? [[name, value]] : [];
2368
+ }));
2063
2369
  const formatStatusLog = (method, path$1, status, ms, errorMessage) => {
2064
2370
  const base = `${method} ${path$1} -> ${status} ${ms}ms`;
2065
2371
  return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
@@ -2087,6 +2393,17 @@ const createServer = (config) => {
2087
2393
  }));
2088
2394
  app.get("/healthz", (c) => c.json({ ok: true }));
2089
2395
  app.route("/v1", claudeRoutes);
2396
+ app.notFound(async (c) => {
2397
+ const message = "Unsupported Claude API route";
2398
+ c.set("requestErrorMessage", message);
2399
+ log.error("Unsupported Claude API request", {
2400
+ method: c.req.method,
2401
+ path: c.req.path,
2402
+ headers: getLoggedHeaders(c.req.raw),
2403
+ payload: await readRequestPayloadForLog(c.req.raw)
2404
+ });
2405
+ return c.json({ error: { message } }, 500);
2406
+ });
2090
2407
  return app;
2091
2408
  };
2092
2409
  const startServer = (config) => serve({