copilot-relay 0.0.5 → 0.0.7

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:
@@ -60,6 +75,9 @@ 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,6 +93,9 @@ 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
 
80
101
  Quick inspection:
@@ -35,6 +35,10 @@ logRetentionDays: 3
35
35
  # Valid values: none, low, medium, high, xhigh.
36
36
  thinkEffort: xhigh
37
37
 
38
+ # Copilot model used for bridge-managed Claude WebSearch.
39
+ # Leave empty to use gptModel.
40
+ webSearchBackend:
41
+
38
42
  # Model routing: requests containing "opus" use opusModel; all others use gptModel.
39
43
  #
40
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
  }
@@ -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
@@ -723,12 +733,21 @@ function buildResponsesRequestPayload(payload, reasoningEffort) {
723
733
  temperature: payload.temperature,
724
734
  top_p: payload.top_p,
725
735
  user: sanitizeUserIdentifier(payload.user),
736
+ prompt_cache_key: deriveResponsesCacheKey(payload),
726
737
  tools: translateTools(payload.tools),
727
738
  tool_choice: translateToolChoice(payload.tool_choice),
728
739
  reasoning: reasoningEffort ? { effort: reasoningEffort } : void 0,
729
740
  text: payload.response_format?.type === "json_object" ? { format: { type: "json_object" } } : void 0
730
741
  };
731
742
  }
743
+ function deriveResponsesCacheKey(payload) {
744
+ const user = sanitizeUserIdentifier(payload.user);
745
+ if (user) return `cr-${createHash("sha256").update(user).digest("hex").slice(0, 32)}`;
746
+ const systemPrompt = payload.messages.find((message) => message.role === "system");
747
+ const systemText = typeof systemPrompt?.content === "string" ? systemPrompt.content : Array.isArray(systemPrompt?.content) ? systemPrompt.content.map((part) => part.type === "text" ? part.text : "").join("") : "";
748
+ if (!systemText) return;
749
+ return `cr-sys-${createHash("sha256").update(systemText).digest("hex").slice(0, 32)}`;
750
+ }
732
751
  function translateResponsesToChatCompletion(response) {
733
752
  const assistantMessages = response.output.filter((item) => item.type === "message");
734
753
  const functionCalls = response.output.filter((item) => item.type === "function_call");
@@ -1724,6 +1743,256 @@ function translateErrorToClaudeErrorEvent() {
1724
1743
  };
1725
1744
  }
1726
1745
 
1746
+ //#endregion
1747
+ //#region src/claude/web-search.ts
1748
+ const anthropicWebSearchToolPattern = /^web_search_\d{8}$/;
1749
+ const claudeCodeWebSearchToolName = "WebSearch";
1750
+ const searchResultLimit = 8;
1751
+ const webSearchInputSchema = {
1752
+ type: "object",
1753
+ properties: { query: {
1754
+ type: "string",
1755
+ description: "The web search query."
1756
+ } },
1757
+ required: ["query"],
1758
+ additionalProperties: false
1759
+ };
1760
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1761
+ const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
1762
+ const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
1763
+ const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
1764
+ const hasClaudeWebSearch = (payload) => payload.tools?.some(isClaudeWebSearchTool) ?? false;
1765
+ const isClaudeWebSearchToolName = (name) => name === "web_search" || name === claudeCodeWebSearchToolName;
1766
+ const getWebSearchBackendModel = (config) => config.webSearchBackend?.trim() || getModelRouting().gptModel;
1767
+ const prepareClaudeWebSearchDecisionPayload = (payload) => {
1768
+ if (!hasClaudeWebSearch(payload)) return payload;
1769
+ return {
1770
+ ...payload,
1771
+ tools: payload.tools?.map((tool) => isClaudeWebSearchTool(tool) ? {
1772
+ ...tool,
1773
+ input_schema: tool.input_schema ?? webSearchInputSchema
1774
+ } : tool)
1775
+ };
1776
+ };
1777
+ const textFromMessageContent = (content) => {
1778
+ if (typeof content === "string") return content;
1779
+ return content.flatMap((block) => {
1780
+ if (block.type === "text") return [block.text];
1781
+ if (block.type === "tool_result") return [block.content];
1782
+ return [];
1783
+ }).join("\n\n");
1784
+ };
1785
+ const getRequestedQuery = (payload) => {
1786
+ const lastUserMessage = [...payload.messages].reverse().find((message) => message.role === "user");
1787
+ const cleaned = (lastUserMessage ? textFromMessageContent(lastUserMessage.content) : "").replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, " ").replace(/\s+/g, " ").trim();
1788
+ return cleaned.match(/\bsearch(?:\s+the\s+web)?(?:\s+for)?\s+(.+)$/i)?.[1]?.trim() || cleaned || "web search";
1789
+ };
1790
+ const getQueryFromToolArguments = (value) => {
1791
+ const trimmed = value.trim();
1792
+ if (!trimmed) return;
1793
+ try {
1794
+ const parsed = JSON.parse(trimmed);
1795
+ if (isRecord(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
1796
+ } catch {
1797
+ return trimmed;
1798
+ }
1799
+ return trimmed;
1800
+ };
1801
+ const getClaudeWebSearchToolCallFromChatResponse = (response, toolNameMapper) => {
1802
+ const toolCall = response.choices.flatMap((choice) => choice.message.tool_calls ?? []).find((call) => isClaudeWebSearchToolName(toolNameMapper.toClaude(call.function.name)));
1803
+ if (!toolCall) return;
1804
+ const query = getQueryFromToolArguments(toolCall.function.arguments);
1805
+ return query ? {
1806
+ query,
1807
+ toolCall
1808
+ } : void 0;
1809
+ };
1810
+ const buildSearchInput = (payload, requestedQuery) => {
1811
+ const systemText = typeof payload.system === "string" ? payload.system : Array.isArray(payload.system) ? payload.system.map((block) => block.text).join("\n\n") : "";
1812
+ const messages = payload.messages.map((message) => `${message.role}: ${textFromMessageContent(message.content)}`).join("\n\n");
1813
+ return [
1814
+ "You are fulfilling an Anthropic web_search server tool request for Claude Code.",
1815
+ "Search the web using the provided web_search_preview tool.",
1816
+ `Requested web search query: ${requestedQuery}`,
1817
+ "Return useful search results as plain text lines in this exact shape:",
1818
+ "1. Title - https://example.com/page",
1819
+ "Include only real source URLs from the search results.",
1820
+ systemText ? `System context:\n${systemText}` : "",
1821
+ `Conversation:\n${messages}`
1822
+ ].filter(Boolean).join("\n\n");
1823
+ };
1824
+ const buildWebSearchRequestPayload = (payload, requestedQuery, model) => ({
1825
+ model,
1826
+ input: buildSearchInput(payload, requestedQuery),
1827
+ tools: [{ type: "web_search_preview" }],
1828
+ max_output_tokens: Math.max(256, Math.min(payload.max_tokens ?? 1024, 1200)),
1829
+ temperature: payload.temperature,
1830
+ top_p: payload.top_p
1831
+ });
1832
+ const getSearchQuery = (response, requestedQuery) => {
1833
+ for (const item of response.output ?? []) {
1834
+ if (item.type !== "web_search_call") continue;
1835
+ const action = isRecord(item.action) ? item.action : void 0;
1836
+ const queries = Array.isArray(action?.queries) ? action.queries : [];
1837
+ const query = typeof action?.query === "string" ? action.query : typeof queries[0] === "string" ? queries[0] : void 0;
1838
+ if (query) return query;
1839
+ }
1840
+ return requestedQuery.slice(0, 200);
1841
+ };
1842
+ const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
1843
+ if (item.type !== "message") return [];
1844
+ return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
1845
+ }).join("\n").trim();
1846
+ const cleanTitle = (line, url) => {
1847
+ return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
1848
+ };
1849
+ const parseSearchResults = (text) => {
1850
+ const results = [];
1851
+ const seenUrls = /* @__PURE__ */ new Set();
1852
+ for (const line of text.split(/\r?\n/)) {
1853
+ const markdownMatch = line.match(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/);
1854
+ const url = markdownMatch?.[2] ?? line.match(/https?:\/\/[^\s)]+/)?.[0];
1855
+ if (!url || seenUrls.has(url)) continue;
1856
+ seenUrls.add(url);
1857
+ results.push({
1858
+ title: markdownMatch?.[1]?.trim() || cleanTitle(line, url),
1859
+ url
1860
+ });
1861
+ if (results.length >= searchResultLimit) break;
1862
+ }
1863
+ return results;
1864
+ };
1865
+ const createFailedSearchExecution = (payload, requestedQuery, model, message) => ({
1866
+ id: `msg_${randomUUID().replaceAll("-", "")}`,
1867
+ inputTokens: 0,
1868
+ model,
1869
+ outputTokens: 0,
1870
+ query: requestedQuery || getRequestedQuery(payload),
1871
+ results: [],
1872
+ text: message
1873
+ });
1874
+ const createClaudeWebSearchExecution = async (config, payload, requestedQuery) => {
1875
+ const backendModel = getWebSearchBackendModel(config);
1876
+ const response = await fetchCopilot(getCopilotProviderContext(config), "/responses", {
1877
+ method: "POST",
1878
+ headers: {
1879
+ accept: "application/json",
1880
+ "content-type": "application/json"
1881
+ },
1882
+ body: JSON.stringify(buildWebSearchRequestPayload(payload, requestedQuery, backendModel))
1883
+ }, { initiator: "agent" });
1884
+ if (!response.ok) {
1885
+ const detail = await response.text().catch(() => "");
1886
+ return createFailedSearchExecution(payload, requestedQuery, backendModel, [`Copilot web search is not available for model ${backendModel}.`, detail ? `Upstream response: ${detail}` : ""].filter(Boolean).join("\n"));
1887
+ }
1888
+ const upstream = await response.json();
1889
+ const text = getResponseText(upstream);
1890
+ const query = getSearchQuery(upstream, requestedQuery);
1891
+ const results = parseSearchResults(text);
1892
+ if (results.length === 0 && !text.trim()) return createFailedSearchExecution(payload, requestedQuery, upstream.model, "Copilot web search did not return search results.");
1893
+ return {
1894
+ id: upstream.id,
1895
+ inputTokens: upstream.usage?.input_tokens ?? 0,
1896
+ model: upstream.model,
1897
+ outputTokens: upstream.usage?.output_tokens ?? 0,
1898
+ query,
1899
+ results,
1900
+ text
1901
+ };
1902
+ };
1903
+ const buildSearchResultBlock = (toolUseId, results) => ({
1904
+ type: "web_search_tool_result",
1905
+ tool_use_id: toolUseId,
1906
+ content: results.length > 0 ? results.map((result) => ({
1907
+ type: "web_search_result",
1908
+ title: result.title,
1909
+ url: result.url,
1910
+ encrypted_content: "",
1911
+ page_age: null
1912
+ })) : {
1913
+ type: "web_search_tool_result_error",
1914
+ error_code: "unavailable"
1915
+ }
1916
+ });
1917
+ const createClaudeWebSearchResponse = (search) => {
1918
+ const toolUseId = `srvtoolu_${randomUUID().replaceAll("-", "")}`;
1919
+ const content = [{
1920
+ type: "server_tool_use",
1921
+ id: toolUseId,
1922
+ name: "web_search",
1923
+ input: { query: search.query }
1924
+ }, buildSearchResultBlock(toolUseId, search.results)];
1925
+ if (search.text) content.push({
1926
+ type: "text",
1927
+ text: search.text
1928
+ });
1929
+ return {
1930
+ id: search.id,
1931
+ type: "message",
1932
+ role: "assistant",
1933
+ content,
1934
+ model: search.model,
1935
+ stop_reason: "end_turn",
1936
+ stop_sequence: null,
1937
+ usage: {
1938
+ input_tokens: search.inputTokens,
1939
+ output_tokens: search.outputTokens,
1940
+ server_tool_use: { web_search_requests: 1 }
1941
+ }
1942
+ };
1943
+ };
1944
+ const getWebSearchResultText = (search) => {
1945
+ if (search.results.length === 0) return search.text || "Web search did not return search results.";
1946
+ return [
1947
+ `Web search results for query: "${search.query}"`,
1948
+ "",
1949
+ ...search.results.map((result, index) => `${index + 1}. ${result.title} - ${result.url}`)
1950
+ ].join("\n");
1951
+ };
1952
+ const createWebSearchResultContextMessage = (search) => ({
1953
+ role: "system",
1954
+ content: [
1955
+ "Trusted bridge retrieval context: the assistant selected web_search, and copilot-relay executed it.",
1956
+ "Use this context for the final answer. Do not describe it as user-provided or injected. Do not call web_search again.",
1957
+ "If the user requested a specific output format, answer using only matching information from this context.",
1958
+ "If the user asked for a URL only, output only that URL with no surrounding text.",
1959
+ "",
1960
+ `Query: ${search.query}`,
1961
+ "",
1962
+ getWebSearchResultText(search)
1963
+ ].join("\n")
1964
+ });
1965
+ const createFinalWebSearchPayload = (payload, search) => {
1966
+ const messages = payload.messages.flatMap((message) => {
1967
+ if (message.role === "tool") return [{
1968
+ role: "developer",
1969
+ content: ["Prior local tool result from the conversation:", String(message.content ?? "")].join("\n")
1970
+ }];
1971
+ if (message.tool_calls && message.tool_calls.length > 0) return message.content ? [{
1972
+ ...message,
1973
+ tool_calls: void 0
1974
+ }] : [];
1975
+ return [message];
1976
+ });
1977
+ return {
1978
+ ...payload,
1979
+ stream: false,
1980
+ tools: void 0,
1981
+ tool_choice: void 0,
1982
+ messages: [...messages, createWebSearchResultContextMessage(search)]
1983
+ };
1984
+ };
1985
+ const mergeWebSearchAndFinalResponse = (searchResponse, finalResponse) => ({
1986
+ ...finalResponse,
1987
+ content: [...searchResponse.content.slice(0, 2), ...finalResponse.content],
1988
+ usage: {
1989
+ ...finalResponse.usage,
1990
+ input_tokens: searchResponse.usage.input_tokens + finalResponse.usage.input_tokens,
1991
+ output_tokens: searchResponse.usage.output_tokens + finalResponse.usage.output_tokens,
1992
+ server_tool_use: { web_search_requests: 1 }
1993
+ }
1994
+ });
1995
+
1727
1996
  //#endregion
1728
1997
  //#region src/lib/tokenizer.ts
1729
1998
  const encodingMap = {
@@ -1928,7 +2197,7 @@ const eventsFromClaudeResponse = (response) => {
1928
2197
  thinking: block.thinking
1929
2198
  }
1930
2199
  });
1931
- else if (block.type === "tool_use") events$1.push({
2200
+ else if (block.type === "tool_use" || block.type === "server_tool_use") events$1.push({
1932
2201
  type: "content_block_delta",
1933
2202
  index,
1934
2203
  delta: {
@@ -1967,15 +2236,38 @@ claudeRoutes.post("/messages", async (c) => {
1967
2236
  log.debug("Full Claude request payload", { payload: claudePayload });
1968
2237
  try {
1969
2238
  const upstreamModel = translateModelName(claudePayload.model);
1970
- const toolNameMapper = createClaudeToolNameMapper(claudePayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
1971
- const response = await createChatCompletions(config, translateToOpenAI(claudePayload, void 0, toolNameMapper), {
2239
+ const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
2240
+ const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
2241
+ const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
2242
+ const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
2243
+ if (shouldLetModelDecideWebSearch) openAIPayload.stream = false;
2244
+ const response = await createChatCompletions(config, openAIPayload, {
1972
2245
  client: "claude",
1973
2246
  requestedModel: claudePayload.model,
1974
2247
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
1975
2248
  requestedThinking: getClaudeRequestedThinking(claudePayload)
1976
2249
  });
1977
2250
  if (isNonStreamingResponse(response)) {
1978
- const claudeResponse = translateToClaude(response, toolNameMapper);
2251
+ const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2252
+ let claudeResponse;
2253
+ if (webSearchToolCall) {
2254
+ const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query);
2255
+ const searchResponse = createClaudeWebSearchResponse(search);
2256
+ if (search.results.length === 0) claudeResponse = searchResponse;
2257
+ else {
2258
+ const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search), {
2259
+ client: "claude",
2260
+ requestedModel: claudePayload.model,
2261
+ requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2262
+ requestedThinking: getClaudeRequestedThinking(claudePayload)
2263
+ });
2264
+ if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2265
+ status: 502,
2266
+ headers: { "content-type": "text/plain" }
2267
+ }));
2268
+ claudeResponse = mergeWebSearchAndFinalResponse(searchResponse, translateToClaude(finalResponse, toolNameMapper));
2269
+ }
2270
+ } else claudeResponse = translateToClaude(response, toolNameMapper);
1979
2271
  if (!claudePayload.stream) return c.json(claudeResponse);
1980
2272
  return streamSSE(c, async (stream) => {
1981
2273
  for (const event of eventsFromClaudeResponse(claudeResponse)) await stream.writeSSE({
@@ -1984,6 +2276,10 @@ claudeRoutes.post("/messages", async (c) => {
1984
2276
  });
1985
2277
  });
1986
2278
  }
2279
+ if (shouldLetModelDecideWebSearch) throw new HTTPError("Claude web search model-decision request unexpectedly streamed", new Response("Claude web search model-decision request unexpectedly streamed", {
2280
+ status: 502,
2281
+ headers: { "content-type": "text/plain" }
2282
+ }));
1987
2283
  return streamSSE(c, async (stream) => {
1988
2284
  const streamState = {
1989
2285
  messageStartSent: false,
@@ -2060,6 +2356,25 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
2060
2356
 
2061
2357
  //#endregion
2062
2358
  //#region src/server.ts
2359
+ const loggedRequestHeaders = [
2360
+ "anthropic-beta",
2361
+ "anthropic-version",
2362
+ "claude-beta",
2363
+ "content-type"
2364
+ ];
2365
+ const readRequestPayloadForLog = async (request) => {
2366
+ const text = await request.clone().text().catch(() => "");
2367
+ if (!text) return;
2368
+ try {
2369
+ return JSON.parse(text);
2370
+ } catch {
2371
+ return text;
2372
+ }
2373
+ };
2374
+ const getLoggedHeaders = (request) => Object.fromEntries(loggedRequestHeaders.flatMap((name) => {
2375
+ const value = request.headers.get(name);
2376
+ return value ? [[name, value]] : [];
2377
+ }));
2063
2378
  const formatStatusLog = (method, path$1, status, ms, errorMessage) => {
2064
2379
  const base = `${method} ${path$1} -> ${status} ${ms}ms`;
2065
2380
  return status >= 400 && errorMessage ? `${base} error=${JSON.stringify(errorMessage)}` : base;
@@ -2087,6 +2402,17 @@ const createServer = (config) => {
2087
2402
  }));
2088
2403
  app.get("/healthz", (c) => c.json({ ok: true }));
2089
2404
  app.route("/v1", claudeRoutes);
2405
+ app.notFound(async (c) => {
2406
+ const message = "Unsupported Claude API route";
2407
+ c.set("requestErrorMessage", message);
2408
+ log.error("Unsupported Claude API request", {
2409
+ method: c.req.method,
2410
+ path: c.req.path,
2411
+ headers: getLoggedHeaders(c.req.raw),
2412
+ payload: await readRequestPayloadForLog(c.req.raw)
2413
+ });
2414
+ return c.json({ error: { message } }, 500);
2415
+ });
2090
2416
  return app;
2091
2417
  };
2092
2418
  const startServer = (config) => serve({