copilot-relay 0.0.5 → 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 +21 -0
- package/config.default.yaml +4 -0
- package/dist/main.js +324 -7
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
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:
|
package/config.default.yaml
CHANGED
|
@@ -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
|
|
@@ -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
|
|
1971
|
-
const
|
|
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
|
|
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({
|