diff --git a/docs/design/claude-code-websearch-flow.md b/docs/design/claude-code-websearch-flow.md new file mode 100644 index 0000000..c0d27c2 --- /dev/null +++ b/docs/design/claude-code-websearch-flow.md @@ -0,0 +1,1482 @@ +# Claude Code WebSearch 完整链路 + +## 1. 架构 + +你的实际架构: + +```text +┌─────────────────────┐ +│ Claude Code │ +│ 客户端 CC │ +└──────────┬──────────┘ + │ + │ Anthropic Messages 协议 + │ POST /v1/messages + ▼ +┌────────────────────────────┐ +│ 你的兼容层 │ +│ 对外伪装 Anthropic API │ +│ │ +│ /v1/messages │ +└──────────┬─────────────────┘ + │ + │ OpenAI-compatible + │ Chat Completions + ▼ +┌────────────────────────────┐ +│ 上游 Chat API │ +│ POST /v1/chat/completions │ +└────────────────────────────┘ +``` + +整个 WebSearch 流程里实际上存在两种不同的工具: + +```text +WebSearch +``` + +和: + +```text +web_search +``` + +它们不是同一个东西。 + +| 工具 | 类型 | 谁声明 | 谁执行 | +| ------------------------------------ | ----------------------- | ---------------------- | ------------------ | +| `WebSearch` | Claude Code client tool | Claude Code | Claude Code | +| `web_search_20250305` / `web_search` | Anthropic server tool | Claude Code 的内部请求 | `/messages` 服务端 | +| 你转给 Chat 的 `web_search` | 普通 function | 你的兼容层 | 你的兼容层 | + +因为你的上游是普通 Chat API,所以: + +```text +Anthropic server tool +``` + +不能直接一一映射成真正的 hosted server tool。 + +你需要在自己的兼容层里模拟 server-tool loop。 + +--- + +# 2. 第一阶段:Claude Code 主请求 + +用户在 Claude Code 中说: + +```text +帮我查一下 OpenAI 最近有什么更新 +``` + +Claude Code 向你的 `/v1/messages` 发请求。 + +例如: + +```http +POST /v1/messages +``` + +```json +{ + "model": "claude-opus-...", + "max_tokens": 32000, + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + } + ], + "tools": [ + { + "name": "WebSearch", + "description": "Search the web", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["query"] + } + }, + { + "name": "Read", + "description": "...", + "input_schema": {} + }, + { + "name": "Bash", + "description": "...", + "input_schema": {} + } + ] +} +``` + +这里: + +```text +WebSearch +``` + +是大写。 + +它是一个普通 Claude Code client tool。 + +所以你的兼容层: + +```text +❌ 不应该在这里执行搜索 +``` + +而是应该把它当普通 function tool 转给上游 Chat。 + +--- + +# 3. 第一次 `/messages` → `/chat/completions` + +你的兼容层转换成: + +```http +POST /v1/chat/completions +``` + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "WebSearch", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "allowed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_domains": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "Read", + "description": "...", + "parameters": {} + } + } + ] +} +``` + +--- + +# 4. 上游 Chat 决定调用 `WebSearch` + +上游 Chat 返回: + +```json +{ + "id": "chatcmpl-main-001", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_search_001", + "type": "function", + "function": { + "name": "WebSearch", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] +} +``` + +注意: + +```text +name = WebSearch +``` + +还是大写的 Claude Code client tool。 + +你的兼容层把它转回 Anthropic: + +```json +{ + "id": "msg_001", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } + } + ], + "stop_reason": "tool_use" +} +``` + +然后: + +```text +你的兼容层 + ↓ +把 tool_use 返回 Claude Code +``` + +此时你的服务器: + +```text +仍然没有搜索。 +``` + +--- + +# 5. Claude Code 收到 `WebSearch` + +Claude Code 收到: + +```json +{ + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +此时执行: + +```text +Claude Code 内置 WebSearch +``` + +也就是说: + +```text + 第一次请求 + +Claude Code + ↓ +你的 /messages + ↓ +上游 Chat + ↓ +tool_call WebSearch + ↓ +你的 /messages + ↓ +tool_use WebSearch + ↓ +Claude Code +``` + +到这里第一阶段结束。 + +--- + +# 6. Claude Code 执行 WebSearch 时再次请求 `/messages` + +Claude Code 为了实现这个 `WebSearch`,会创建一个内部的 side request。 + +这是: + +```text +第二次独立的 /v1/messages 请求 +``` + +示意结构: + +```http +POST /v1/messages +``` + +```json +{ + "model": "claude-haiku-...", + "max_tokens": 32000, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Perform a web search for the query: OpenAI latest updates 2026" + } + ] + } + ], + "tools": [ + { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 8 + } + ], + "tool_choice": { + "type": "tool", + "name": "web_search" + } +} +``` + +注意第二次已经不是: + +```text +WebSearch +``` + +而是: + +```text +web_search +``` + +并且有特殊 type: + +```text +web_search_20250305 +``` + +这表示: + +```text +Anthropic server tool +``` + +--- + +# 7. Query 怎么从第一次传到第二次? + +第一阶段主模型产生: + +```json +{ + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +Claude Code 收到以后: + +```text +query = +"OpenAI latest updates 2026" +``` + +然后 CC 的 WebSearch implementation 创建第二次 side request。 + +示意: + +```text +WebSearch.input.query + +"OpenAI latest updates 2026" + + ↓ + + Claude Code + + ↓ + +第二次 /messages 输入 + +"Perform a web search for the query: + OpenAI latest updates 2026" +``` + +重点是: + +```text +web_search_20250305 的 tool definition +本身没有 query。 +``` + +它只是: + +```json +{ + "type": "web_search_20250305", + "name": "web_search" +} +``` + +query 存在于这次模型输入的语义里。 + +之后模型应该生成: + +```json +{ + "type": "server_tool_use", + "name": "web_search", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +Anthropic 原生 API 会在这里执行搜索。 + +但是: + +```text +你不是 Anthropic。 + +你的上游也不是 Anthropic server-tool runtime。 + +你的上游是 Chat API。 +``` + +所以这一部分需要你自己模拟。 + +--- + +# 8. 你的兼容层收到第二次 `/messages` + +现在你收到: + +```json +{ + "messages": [ + { + "role": "user", + "content": "Perform a web search for the query: OpenAI latest updates 2026" + } + ], + "tools": [ + { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 8 + } + ], + "tool_choice": { + "type": "tool", + "name": "web_search" + } +} +``` + +这一次: + +```text +✅ 你的服务端必须处理 web_search +``` + +因为从 Claude Code 看: + +```text +你的 /messages API += +Anthropic server +``` + +--- + +# 9. `web_search` server tool 怎么转换给 Chat? + +普通 Chat API 不理解: + +```json +{ + "type": "web_search_20250305" +} +``` + +所以你把它降级成一个普通 function: + +```json +{ + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } +} +``` + +也就是说: + +```text +Anthropic Server Tool +web_search + ↓ +你的兼容层 + ↓ +Chat Function Tool +web_search +``` + +注意这个 Chat function 是: + +```text +你的内部实现细节。 +``` + +它绝对不能直接返回给 Claude Code。 + +--- + +# 10. 第二次 `/messages` → 第一次内部 `/chat/completions` + +你发给上游: + +```http +POST /v1/chat/completions +``` + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "system", + "content": "You are an assistant for performing a web search." + }, + { + "role": "user", + "content": "Perform a web search for the query: OpenAI latest updates 2026" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } + } + ], + "tool_choice": { + "type": "function", + "function": { + "name": "web_search" + } + } +} +``` + +这里非常关键: + +```text +你不需要自己从: + +"Perform a web search for the query: ..." + +parse query。 +``` + +因为你可以让 Chat 模型自己产生 function arguments。 + +--- + +# 11. Chat 上游生成真正的搜索 query + +上游返回: + +```json +{ + "id": "chatcmpl-side-001", + "choices": [ + { + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_internal_search_001", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] +} +``` + +这里: + +```json +{ + "query": "OpenAI latest updates 2026" +} +``` + +才是: + +```text +真正应该交给搜索引擎的 query。 +``` + +完整路径: + +```text +外层主模型 +WebSearch.input.query + ↓ +Claude Code + ↓ +side request messages + ↓ +上游 Chat 模型 + ↓ +web_search function arguments + ↓ +你的搜索实现 +``` + +--- + +# 12. 你的兼容层执行真正搜索 + +现在你的兼容层拿到: + +```json +{ + "query": "OpenAI latest updates 2026" +} +``` + +执行真正的 search: + +```text +search("OpenAI latest updates 2026") +``` + +例如得到: + +```json +[ + { + "title": "OpenAI announces ...", + "url": "https://example.com/1", + "snippet": "..." + }, + { + "title": "OpenAI API update ...", + "url": "https://example.com/2", + "snippet": "..." + } +] +``` + +这一步: + +```text +✅ 发生在你的兼容层 +``` + +因为上游只是普通 Chat API。 + +--- + +# 13. 把 search result 回传给 Chat 上游 + +现在需要完成普通 Chat function-call loop。 + +再次调用: + +```http +POST /v1/chat/completions +``` + +messages: + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "system", + "content": "You are an assistant for performing a web search." + }, + { + "role": "user", + "content": "Perform a web search for the query: OpenAI latest updates 2026" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_internal_search_001", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_internal_search_001", + "content": "[{\"title\":\"OpenAI announces ...\",\"url\":\"https://example.com/1\",\"snippet\":\"...\"},{\"title\":\"OpenAI API update ...\",\"url\":\"https://example.com/2\",\"snippet\":\"...\"}]" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "web_search", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": ["query"] + } + } + } + ] +} +``` + +--- + +# 14. 上游模型基于搜索结果回答 + +现在上游看到: + +```text +用户要求搜索 Q + ++ + +自己调用过 web_search(Q) + ++ + +tool result 里有真实搜索结果 +``` + +于是返回: + +```json +{ + "id": "chatcmpl-side-002", + "choices": [ + { + "message": { + "role": "assistant", + "content": "根据搜索结果,OpenAI 最近发布了……" + }, + "finish_reason": "stop" + } + ] +} +``` + +这就是: + +```text +基于 Web Search 的模型回答 +``` + +--- + +# 15. 如果上游再次调用 `web_search` + +不能假设只有一次。 + +例如第二轮可能返回: + +```json +{ + "tool_calls": [ + { + "id": "call_internal_search_002", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"OpenAI September 2026 API announcements\"}" + } + } + ] +} +``` + +那你的兼容层再次: + +```text +执行搜索 +↓ +追加 tool result +↓ +再次请求 Chat +``` + +所以内部需要: + +```text +while model requests web_search: + execute search + append tool result + call chat again +``` + +并且应该尊重 Anthropic side request 里的: + +```json +{ + "max_uses": 8 +} +``` + +例如: + +```text +最多允许 8 次内部搜索 +``` + +--- + +# 16. 这整个过程对 Claude Code 是隐藏的 + +第二次 `/messages` 请求期间: + +```text +Claude Code + │ + │ POST /messages + ▼ +你的兼容层 + + Chat call #1 + ↓ + web_search function_call + ↓ + 你真正搜索 + ↓ + Chat call #2 + ↓ + 如果还有搜索 + ↓ + 再搜索 / 再 Chat + ↓ + 最终 text + + │ + ▼ +Claude Code +``` + +Claude Code 不会看到: + +```text +Chat function_call +``` + +也不会看到: + +```text +你的搜索 provider +``` + +因为从 Claude Code 看: + +```text +web_search 是 server tool。 +``` + +所以 server-side loop 应该完全在你的 `/messages` 请求内部完成。 + +--- + +# 17. 第二次 `/messages` 最终回包 + +假设最终上游返回: + +```text +根据搜索结果,OpenAI 最近…… +``` + +最简单的兼容方式可以返回: + +```json +{ + "id": "msg_side_final", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "根据搜索结果,OpenAI 最近……" + } + ], + "stop_reason": "end_turn" +} +``` + +Claude Code 收到。 + +--- + +# 18. Claude Code 完成外层 `WebSearch` + +还记得最开始那个: + +```json +{ + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } +} +``` + +吗? + +Claude Code 现在拿到了第二次 side request 的结果: + +```text +根据搜索结果,OpenAI 最近…… +``` + +于是把它当成大写 `WebSearch` 的: + +```text +tool_result +``` + +接着第三次调用你的 `/messages`。 + +--- + +# 19. Claude Code 回到主 Agent + +例如: + +```http +POST /v1/messages +``` + +```json +{ + "model": "claude-opus-...", + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_search_001", + "name": "WebSearch", + "input": { + "query": "OpenAI latest updates 2026" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_search_001", + "content": "根据搜索结果,OpenAI 最近……" + } + ] + } + ], + "tools": [ + { + "name": "WebSearch", + "input_schema": { + "...": "..." + } + }, + { + "name": "Read", + "input_schema": { + "...": "..." + } + } + ] +} +``` + +注意: + +```text +现在又回到了大写 WebSearch。 +``` + +因为这是主 Agent 的 client-tool history。 + +--- + +# 20. 主 Agent continuation → Chat + +你的兼容层把它转换为 Chat: + +```json +{ + "model": "your-upstream-model", + "messages": [ + { + "role": "user", + "content": "帮我查一下 OpenAI 最近有什么更新" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_search_001", + "type": "function", + "function": { + "name": "WebSearch", + "arguments": "{\"query\":\"OpenAI latest updates 2026\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_search_001", + "content": "根据搜索结果,OpenAI 最近……" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "WebSearch", + "parameters": { + "...": "..." + } + } + } + ] +} +``` + +--- + +# 21. 上游主模型最终回答 + +Chat 返回: + +```json +{ + "choices": [ + { + "message": { + "role": "assistant", + "content": "OpenAI 最近主要有这些更新:……" + }, + "finish_reason": "stop" + } + ] +} +``` + +你的兼容层转换: + +```json +{ + "id": "msg_final", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "OpenAI 最近主要有这些更新:……" + } + ], + "stop_reason": "end_turn" +} +``` + +返回 Claude Code。 + +至此整个 WebSearch 完成。 + +--- + +# 22. 完整时序图 + +```text +Claude Code 你的 /messages 上游 Chat + │ │ │ + │ ① 主请求 │ │ + │ tools=[WebSearch,...] │ │ + ├─────────────────────────────>│ │ + │ │ │ + │ │ ② 转普通 function │ + │ │ WebSearch │ + │ ├───────────────────────────>│ + │ │ │ + │ │ tool_call WebSearch(Q) │ + │ │<───────────────────────────┤ + │ │ │ + │ ③ tool_use WebSearch(Q) │ │ + │<─────────────────────────────┤ │ + │ │ │ + │ │ │ + │ CC 执行 WebSearch │ │ + │ │ │ + │ ④ side request │ │ + │ tools=[web_search_...] │ │ + ├─────────────────────────────>│ │ + │ │ │ + │ │ ⑤ 转内部 function │ + │ │ web_search │ + │ ├───────────────────────────>│ + │ │ │ + │ │ tool_call web_search(Q2) │ + │ │<───────────────────────────┤ + │ │ │ + │ │ │ + │ ┌──────┴──────┐ │ + │ │ 真正执行搜索 │ │ + │ └──────┬──────┘ │ + │ │ │ + │ │ ⑥ tool result │ + │ ├───────────────────────────>│ + │ │ │ + │ │ 基于搜索结果的 text │ + │ │<───────────────────────────┤ + │ │ │ + │ ⑦ side request 最终回答 │ │ + │<─────────────────────────────┤ │ + │ │ │ + │ CC 将它包装成 │ │ + │ WebSearch tool_result │ │ + │ │ │ + │ ⑧ 主 Agent continuation │ │ + ├─────────────────────────────>│ │ + │ │ │ + │ │ ⑨ function result │ + │ ├───────────────────────────>│ + │ │ │ + │ │ 最终主回答 │ + │ │<───────────────────────────┤ + │ │ │ + │ ⑩ 最终回答 │ │ + │<─────────────────────────────┤ │ +``` + +--- + +# 23. 最重要的两条分支 + +你的 `/messages` handler 应该明确区分: + +## A. 大写 `WebSearch` + +```json +{ + "name": "WebSearch", + "input_schema": {} +} +``` + +处理方式: + +```text +→ 普通 client tool +→ 转成 Chat function +→ 上游调用后 +→ 转成 Anthropic tool_use +→ 返回 Claude Code +→ 绝对不要自己执行搜索 +``` + +--- + +## B. 小写 `web_search_*` + +```json +{ + "type": "web_search_20250305", + "name": "web_search" +} +``` + +处理方式: + +```text +→ Anthropic server tool +→ 不应该把 tool call 暴露给 Claude Code +→ 转成你内部的 Chat function +→ Chat 生成 query +→ 你的兼容层执行搜索 +→ tool result 回 Chat +→ Chat 继续生成 +→ 整个内部 loop 完成后 +→ 才返回这次 /messages 请求 +``` + +--- + +# 24. 核心代码逻辑 + +整体可以理解为: + +```go +func HandleMessages(req AnthropicRequest) AnthropicResponse { + if hasServerWebSearch(req.Tools) { + return handleServerWebSearch(req) + } + + return handleNormalClaudeCodeRequest(req) +} +``` + +普通主 Agent: + +```go +func handleNormalClaudeCodeRequest( + req AnthropicRequest, +) AnthropicResponse { + + chatReq := convertAnthropicToChat(req) + + chatResp := callUpstreamChat(chatReq) + + // 如果 Chat 调用的是 WebSearch / Read / Bash 等 + // 不执行。 + // 直接转换成 Anthropic tool_use 返回 CC。 + + return convertChatToAnthropic(chatResp) +} +``` + +Server WebSearch: + +```go +func handleServerWebSearch( + req AnthropicRequest, +) AnthropicResponse { + + chatReq := convertServerWebSearchToChat(req) + + searchCount := 0 + maxUses := getMaxUses(req) // 例如 8 + + for { + chatResp := callUpstreamChat(chatReq) + + call := findToolCall(chatResp, "web_search") + + if call == nil { + // 模型已经生成最终答案 + return convertFinalChatToAnthropic(chatResp) + } + + if searchCount >= maxUses { + return errorOrForceFinish() + } + + query := call.Arguments.Query + + result := executeWebSearch(query) + + chatReq.Messages = append( + chatReq.Messages, + chatResp.AssistantMessage, + ChatMessage{ + Role: "tool", + ToolCallID: call.ID, + Content: serialize(result), + }, + ) + + searchCount++ + } +} +``` + +--- + +# 25. 最终关系 + +可以压缩成一句话: + +```text +Claude Code 大写 WebSearch + ↓ +你的 API 当普通 function 转给 Chat + ↓ +Chat 返回 WebSearch tool call + ↓ +你的 API 返回给 Claude Code + ↓ +Claude Code 执行 WebSearch + ↓ +Claude Code 再调用你的 /messages, +这一次带小写 web_search server tool + ↓ +你的 API 把它变成内部 Chat function + ↓ +Chat 产生真正 query + ↓ +你的 API 真正搜索 + ↓ +search result → Chat + ↓ +Chat 生成基于搜索结果的回答 + ↓ +你的 API 返回 Claude Code + ↓ +Claude Code 包装成外层 WebSearch tool_result + ↓ +主 Agent 继续 +``` + +--- + +# 26. 一定不要做错的地方 + +错误: + +```text +主请求看到: + +WebSearch + +↓ + +你的服务直接搜索 +``` + +因为这样 Claude Code 永远收不到: + +```text +tool_use WebSearch +``` + +会破坏 CC 的 client-tool lifecycle。 + +正确: + +```text +主请求 WebSearch +↓ +返回 CC 执行 + +side request web_search_* +↓ +你的服务内部执行 +``` + +另外也不要: + +```text +收到 web_search_* 后 +直接从 user prompt 正则提取 query +然后搜索 +``` + +更接近 Anthropic server-tool 语义的是: + +```text +messages ++ +web_search function schema +↓ +Chat 模型生成结构化 query +↓ +你的服务执行搜索 +``` + +因此模型仍然负责: + +```text +tool selection / query generation +``` + +而你的兼容层负责: + +```text +tool execution / continuation loop +``` + +这就是普通 Chat 上游情况下最干净的实现。 diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 5e26861..424ee9d 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -1,15 +1,13 @@ import type { NextRequest } from 'next/server'; import type { DebugTrace } from '../domain/debug'; +import { withCodeBuddyToken } from '../search/token'; import { anthropicErrorType, createAnthropicError, getUpstreamErrorMessage, } from './anthropic/errors'; -import { - buildChatRequestBody, - shouldBridgeAnthropicServerTools, -} from './anthropic/request'; +import { buildChatRequestBody } from './anthropic/request'; import { mapOpenAIResponseToAnthropic } from './anthropic/response'; import { createAnthropicServerToolEventStream, @@ -19,8 +17,18 @@ import type { AnthropicMessagesRequestBody, OpenAIChatResponse, } from './anthropic/types'; -import { proxyChatCompletions, type ChatRequestBody } from './codebuddy'; -import { getServerToolExecutions, getServerToolTurns } from './web-search-loop'; +import { + proxyChatCompletions, + resolveProxyContext, + type ChatRequestBody, + type ProxyContext, +} from './codebuddy'; +import { + hasExecutableServerTool, + prepareServerToolTurn, + reconcileToolChoice, + runServerToolTurn, +} from './server-tools'; // --------------------------------------------------------------------------- // Main handler @@ -37,23 +45,100 @@ export const handleMessagesRequest = async ( try { const chatBody = await buildChatRequestBody(body); + const model = String(chatBody.model ?? 'unknown'); + + // Classified on the translated tools: the translator keeps a + // provider-executed declaration's type, so `web_search_20250305` is still + // recognisable here, while the client's own `WebSearch` has become an + // ordinary function and is left alone. + const prepared = await prepareServerToolTurn(chatBody.tools); + const rewrite = prepared?.rewrite ?? null; - if (body.stream && (await shouldBridgeAnthropicServerTools(body.tools))) { - return createAnthropicServerToolEventStream( + // The declarations have to be rewritten even when nothing is executed: + // upstream has no server tools, so leaving `web_search_20250305` in the + // request would send a shape it rejects. A declaration the proxy is not + // running becomes an ordinary function, and the call that comes back goes + // to the client. + const upstreamTools = rewrite + ? rewrite.tools + : ((chatBody.tools as unknown[] | undefined) ?? undefined); + + /** + * One round trip upstream. + * + * The tools come from `turnBody`, never pinned back on here: the turn + * decides what to offer on each hop, and overriding it would undo the + * withdrawal it does once the search budget is spent. + */ + const callUpstream = + (context?: ProxyContext) => + (turnBody: ChatRequestBody, stream: boolean): Promise => + proxyChatCompletions( + request, + { ...turnBody, stream }, + context, + debugTrace, + '/v1/messages', + ); + + if (rewrite && prepared && hasExecutableServerTool(rewrite.executable)) { + const { fetchProvider, searchProvider } = prepared.providers; + + // Resolved here rather than inside the call so the CodeBuddy backends can + // be scoped to this request's credential: they call the agent-tool + // endpoints with the same token the model call used. + const context = await resolveProxyContext( request, - chatBody, - String(chatBody.model ?? 'unknown'), - debugTrace, + typeof chatBody.model === 'string' ? chatBody.model : undefined, + ); + + const runTurn = () => + withCodeBuddyToken( + () => Promise.resolve(context.auth.bearerToken), + () => + runServerToolTurn({ + body: { ...chatBody, tools: rewrite.tools } as ChatRequestBody, + callUpstream: callUpstream(context), + fetchProvider, + rewrite, + searchProvider, + signal: request.signal, + }), + ); + + if (body.stream) { + return createAnthropicServerToolEventStream({ model, runTurn }); + } + + const { executions, response, segments } = await runTurn(); + + if (!response.ok) { + return createAnthropicError( + response.status, + await getUpstreamErrorMessage(response), + ); + } + + const payload = (await response.json()) as OpenAIChatResponse; + + return Response.json( + mapOpenAIResponseToAnthropic(payload, model, executions, segments), ); } - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as ChatRequestBody, - undefined, - debugTrace, - '/v1/messages', - { findingsAsStructuredBlocks: true }, + const upstreamResponse = await callUpstream()( + // `upstreamTools` matters here even though no turn runs: when a server + // tool is declared but nothing on this deployment can execute it, the + // declaration still has to be rewritten, or upstream is sent a + // `web_search_20250305` type it has never heard of. + { + ...chatBody, + tools: upstreamTools, + // A server tool nothing here can run is withdrawn from `tools`, so a + // choice forcing it has to go too. + tool_choice: reconcileToolChoice(chatBody.tool_choice, upstreamTools), + } as ChatRequestBody, + Boolean(body.stream), ); if (!upstreamResponse.ok) { @@ -63,22 +148,13 @@ export const handleMessagesRequest = async ( ); } - const model = String(chatBody.model ?? 'unknown'); - const serverToolExecutions = getServerToolExecutions(upstreamResponse); - // Carried beside the response rather than inside it: the OpenAI-shaped - // payload the loop emits must stay protocol-clean for chat-completions - // clients, so this file reads the grouping off the response itself. - const turns = getServerToolTurns(upstreamResponse); - if (body.stream) { return mapOpenAIStreamToAnthropicSSE(upstreamResponse, model); } const payload = (await upstreamResponse.json()) as OpenAIChatResponse; - return Response.json( - mapOpenAIResponseToAnthropic(payload, model, serverToolExecutions, turns), - ); + return Response.json(mapOpenAIResponseToAnthropic(payload, model)); } catch (error) { return createAnthropicError( 500, diff --git a/lib/server/proxy/anthropic/request.ts b/lib/server/proxy/anthropic/request.ts index d4fb6ce..510cbf1 100644 --- a/lib/server/proxy/anthropic/request.ts +++ b/lib/server/proxy/anthropic/request.ts @@ -1,15 +1,8 @@ -import { - getDefaultModel, - isWebFetchEnabled, - isWebSearchEnabled, -} from '../../domain/config'; +import { getDefaultModel } from '../../domain/config'; import { stringifyContent } from '../../shared/content'; import { - markServerTool, normalizeToolName, - WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX, - WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_TYPE_PREFIX, } from '../../search/tool'; import { @@ -330,6 +323,19 @@ export const mapAnthropicMessagesToChat = ( return result; }; +/** + * Translates Anthropic tool declarations into the chat shape upstream takes. + * + * A provider-executed declaration — `web_search_20250305`, + * `web_fetch_20250910` — keeps its declared type rather than being flattened to + * `function`. That type is the only thing distinguishing a server tool from the + * client's own function, and Claude Code relies on the difference: it declares + * `WebSearch` as an ordinary function and resolves it itself, so a translation + * that collapsed the two would hand a client-owned tool to the proxy. + * + * Nothing sends the preserved type upstream: a request carrying one is always + * rewritten before it leaves, because upstream has no server tools. + */ export const mapAnthropicToolsToChat = ( tools: AnthropicTool[] | undefined, ): unknown[] | undefined => { @@ -338,7 +344,24 @@ export const mapAnthropicToolsToChat = ( } return tools.map((tool) => { - const mapped = { + const type = typeof tool.type === 'string' ? tool.type.trim() : ''; + const serverDeclared = [ + WEB_SEARCH_TOOL_TYPE_PREFIX, + WEB_FETCH_TOOL_TYPE_PREFIX, + ].some((prefix) => + normalizeToolName(type).startsWith(normalizeToolName(prefix)), + ); + + if (serverDeclared) { + // Everything the client declared travels with it — `max_uses`, + // `allowed_domains`, `user_location`. Only the *shape* changes: upstream + // is a Chat API, so the declaration has to look like a function, while + // the declared type is kept on `type` so the proxy can still recognise + // it as a server tool downstream. + return { ...tool, type, function: { name: tool.name } }; + } + + return { type: 'function', function: { name: tool.name, @@ -346,34 +369,9 @@ export const mapAnthropicToolsToChat = ( parameters: tool.input_schema, }, }; - const normalizedType = normalizeToolName(tool.type ?? ''); - const serverDeclared = [ - WEB_SEARCH_TOOL_TYPE_PREFIX, - WEB_FETCH_TOOL_TYPE_PREFIX, - ].some((prefix) => normalizedType.startsWith(normalizeToolName(prefix))); - - return serverDeclared ? markServerTool(mapped) : mapped; }); }; -export const shouldBridgeAnthropicServerTools = async ( - tools: AnthropicTool[] | undefined, -): Promise => { - const names = new Set( - (tools ?? []).map((tool) => normalizeToolName(tool.name)), - ); - const [searchEnabled, fetchEnabled] = await Promise.all([ - names.has(normalizeToolName(WEB_SEARCH_TOOL_NAME)) - ? isWebSearchEnabled() - : false, - names.has(normalizeToolName(WEB_FETCH_TOOL_NAME)) - ? isWebFetchEnabled() - : false, - ]); - - return searchEnabled || fetchEnabled; -}; - export const mapAnthropicToolChoiceToChat = (toolChoice: unknown): unknown => { if (!toolChoice || typeof toolChoice !== 'object') { return toolChoice; diff --git a/lib/server/proxy/anthropic/response.ts b/lib/server/proxy/anthropic/response.ts index d4c57a3..64ed241 100644 --- a/lib/server/proxy/anthropic/response.ts +++ b/lib/server/proxy/anthropic/response.ts @@ -4,7 +4,7 @@ import type { OpenAIChatResponse, OpenAIUsage, } from './types'; -import type { ServerToolExecution, ServerToolTurn } from '../web-search-loop'; +import type { ServerToolExecution, ServerToolSegment } from '../server-tools'; // --------------------------------------------------------------------------- // Response translation: OpenAI → Anthropic (non-streaming) @@ -121,47 +121,32 @@ export const buildThinkingBlock = ( }); /** - * Lays a server-tool turn out the way Anthropic does: each hop contributes its - * own thinking and text, followed by the tool blocks that hop triggered. + * Lays a server-tool turn out the way Anthropic does: what the model wrote + * before the search, the search itself, then the answer the results produced. * - * `turns` carries the per-hop grouping the OpenAI-shaped payload cannot. Under - * that protocol a multi-hop turn collapses into one `content` string and one - * `reasoning_content` string, which loses where one hop's reasoning ends and the - * next begins — so the grouping has to be recovered before it is joined, which - * is why the loop emits it alongside the strings rather than this file - * reconstructing it. - * - * Anthropic's own server tools run multiple hops inside one assistant message, - * and a client replaying that message expects `[thinking] [text] [tool_use] - * [tool_result] [thinking] [text]`. Gathering the blocks by kind instead — every - * tool ahead of all the prose — puts each search before the reasoning that asked - * for it and merges hops that were never contiguous. + * The order is the whole point. A client replays this content array as the + * assistant turn, and Anthropic's own server tools interleave — `[thinking] + * [text] [server_tool_use] [web_search_tool_result] [text]` — so gathering the + * blocks by kind instead would show every search ahead of the reasoning that + * asked for it, and put the conclusion before its evidence. */ -export const buildAnthropicTurnBlocks = ( - turns: ServerToolTurn[], -): AnthropicContentBlock[] => { - const blocks: AnthropicContentBlock[] = []; - - turns.forEach((turn) => { - if (turn.reasoning) { - blocks.push(buildThinkingBlock(turn.reasoning)); - } - - if (turn.text) { - blocks.push({ type: 'text', text: turn.text }); - } - - blocks.push(...buildAllAnthropicServerToolBlocks(turn.executions)); - }); - - return blocks; -}; +export const buildAnthropicServerToolTurnBlocks = ( + segments: ServerToolSegment[], +): AnthropicContentBlock[] => + // Interleaved, not gathered by kind: each hop's prose belongs immediately + // before the blocks it asked for. Collecting all the prose first would show + // the user a conclusion ahead of the search that produced it. + segments.flatMap((segment) => [ + ...(segment.reasoning ? [buildThinkingBlock(segment.reasoning)] : []), + ...(segment.text ? [{ text: segment.text, type: 'text' as const }] : []), + ...buildAllAnthropicServerToolBlocks(segment.executions), + ]); export const mapOpenAIResponseToAnthropic = ( openaiResponse: OpenAIChatResponse, model: string, serverToolExecutions: ServerToolExecution[] = [], - turns?: ServerToolTurn[], + segments?: ServerToolSegment[], ): Record => { const choice = openaiResponse.choices?.[0]; const message = choice?.message; @@ -173,15 +158,11 @@ export const mapOpenAIResponseToAnthropic = ( const textContent = typeof message?.content === 'string' ? message.content : ''; - // With per-hop grouping the turns already hold every block in order, prose - // included. Without it — no server tool ran, or a path that never grouped the - // hops — fall back to Anthropic's own order: thinking and text first, then - // the server-tool blocks they led to. - const contentBlocks: AnthropicContentBlock[] = turns - ? buildAnthropicTurnBlocks(turns) + const contentBlocks: AnthropicContentBlock[] = segments + ? buildAnthropicServerToolTurnBlocks(segments) : []; - if (!turns) { + if (!segments) { if (reasoningText) { contentBlocks.push(buildThinkingBlock(reasoningText)); } @@ -193,6 +174,16 @@ export const mapOpenAIResponseToAnthropic = ( contentBlocks.push( ...buildAllAnthropicServerToolBlocks(serverToolExecutions), ); + } else { + // The closing half of the turn: the answer written once the results were + // in. It follows every block above rather than preceding them. + if (reasoningText) { + contentBlocks.push(buildThinkingBlock(reasoningText)); + } + + if (textContent) { + contentBlocks.push({ type: 'text', text: textContent }); + } } // Tool calls diff --git a/lib/server/proxy/anthropic/stream.ts b/lib/server/proxy/anthropic/stream.ts index 7e729fa..10a7081 100644 --- a/lib/server/proxy/anthropic/stream.ts +++ b/lib/server/proxy/anthropic/stream.ts @@ -1,13 +1,14 @@ -import type { NextRequest } from 'next/server'; - -import type { DebugTrace } from '../../domain/debug'; -import { createSseResponse } from '../../shared/sse'; +import { createSseResponse, isEventStream } from '../../shared/sse'; import { anthropicStreamErrorChunks, createStreamCloser, toUpstreamTimeoutMessage, } from '../../shared/upstream-timeout'; -import { proxyChatCompletions, type ChatRequestBody } from '../codebuddy'; +import { + type ChatCompletionPayload, + type ServerToolTurnOutcome, + synthesizeChatCompletionStream, +} from '../server-tools'; import { createAnthropicId } from './content'; import { anthropicErrorType, getUpstreamErrorMessage } from './errors'; import { @@ -22,11 +23,8 @@ import type { OpenAIUsage, StreamingToolUseState, } from './types'; -import { - getServerToolExecutions, - getServerToolStreamEvent, - type ServerToolExecution, -} from '../web-search-loop'; +import type { ServerToolExecution } from '../server-tools'; +import { getServerToolExecutions } from '../server-tools'; // --------------------------------------------------------------------------- // Response translation: OpenAI SSE → Anthropic SSE (streaming) @@ -52,7 +50,6 @@ export const mapOpenAIStreamToAnthropicSSE = ( const messageId = options?.messageId ?? createAnthropicId('msg'); const serverToolExecutions = options?.serverToolExecutions ?? getServerToolExecutions(upstreamResponse); - const serverToolUseIds = new Map(); const toolUseStates = new Map(); let nextToolIndex = 0; let started = options?.emitMessageStart === false; @@ -372,59 +369,8 @@ export const mapOpenAIStreamToAnthropicSSE = ( try { const chunk = JSON.parse(raw) as OpenAIStreamChunk; - const serverToolEvent = getServerToolStreamEvent(chunk); - - if (serverToolEvent?.phase === 'call') { - closeOpenTextBlocks(); - const index = contentBlockCount++; - const toolUseId = createAnthropicId('srvtoolu'); - serverToolUseIds.set(serverToolEvent.invocation.id, toolUseId); - enqueueEvent({ - type: 'content_block_start', - index, - content_block: { - type: 'server_tool_use', - id: toolUseId, - name: serverToolEvent.invocation.type, - input: {}, - }, - }); - enqueueEvent({ - type: 'content_block_delta', - index, - delta: { - type: 'input_json_delta', - partial_json: JSON.stringify( - serverToolEvent.invocation.input, - ), - }, - }); - enqueueEvent({ type: 'content_block_stop', index }); - continue; - } - - if (serverToolEvent?.phase === 'result') { - closeOpenTextBlocks(); - serverToolExecutions.push(serverToolEvent.execution); - const index = contentBlockCount++; - const resultBlock = buildAnthropicServerToolBlocks( - serverToolEvent.execution, - )[1]; - enqueueEvent({ - type: 'content_block_start', - index, - content_block: { - ...resultBlock, - tool_use_id: serverToolUseIds.get( - serverToolEvent.execution.id, - ), - }, - }); - enqueueEvent({ type: 'content_block_stop', index }); - continue; - } - const upstreamError = chunk as OpenAIStreamError; + if (upstreamError.error?.message) { rejectStream( upstreamError.error.message, @@ -511,12 +457,28 @@ export const mapOpenAIStreamToAnthropicSSE = ( return createSseResponse(stream, { status: 200 }); }; -export const createAnthropicServerToolEventStream = ( - request: NextRequest, - chatBody: Record, - model: string, - debugTrace?: DebugTrace, -): Response => { +/** + * Streams a turn in which a server tool runs. + * + * `message_start` goes out before the first upstream call, so the client gets + * headers and a message id immediately rather than after the search completes. + * The blocks that follow are the ones Anthropic's own server tools produce, in + * the order they produce them: whatever the model said before searching, the + * `server_tool_use` and `web_search_tool_result` pairs, then the answer the + * results produced — which arrives from upstream as an ordinary stream and is + * mapped by the normal path. + */ +export const createAnthropicServerToolEventStream = ({ + model, + runTurn, +}: { + model: string; + /** + * Runs the server-tool turn. Provided by the caller because only it knows + * which backends are configured and how to reach upstream for this request. + */ + runTurn: () => Promise; +}): Response => { const encoder = new TextEncoder(); const messageId = createAnthropicId('msg'); let activeReader: ReadableStreamDefaultReader | null = null; @@ -533,6 +495,48 @@ export const createAnthropicServerToolEventStream = ( ); }; + /** + * Emits a text-like block the way Anthropic streams one: an empty + * `content_block_start`, then the content as a delta, then the stop. + * Returns nothing; the caller advances the index when it emitted one. + */ + const emitText = ( + blockIndex: number, + type: string, + content: string, + deltaType: string, + ): void => { + if (!content) { + return; + } + + const field = type === 'thinking' ? 'thinking' : 'text'; + + enqueueEvent({ + type: 'content_block_start', + index: blockIndex, + content_block: { type, [field]: '' }, + }); + enqueueEvent({ + type: 'content_block_delta', + index: blockIndex, + delta: { type: deltaType, [field]: content }, + }); + enqueueEvent({ type: 'content_block_stop', index: blockIndex }); + }; + + const emitBlock = ( + index: number, + contentBlock: Record, + ): void => { + enqueueEvent({ + content_block: contentBlock, + index, + type: 'content_block_start', + }); + enqueueEvent({ type: 'content_block_stop', index }); + }; + enqueueEvent({ type: 'message_start', message: { @@ -553,55 +557,94 @@ export const createAnthropicServerToolEventStream = ( }); const run = async (): Promise => { - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as ChatRequestBody, - undefined, - debugTrace, - '/v1/messages', - // The findings reach the client as `web_search_tool_result` blocks, - // so the loop must not also fold them into the assistant text. - { emitStreamEvents: true, findingsAsStructuredBlocks: true }, - ); + const { executions, response, segments } = await runTurn(); if (cancelled) { - await upstreamResponse.body?.cancel(); + await response.body?.cancel(); return; } - if (!upstreamResponse.ok || !upstreamResponse.body) { + let index = 0; + + // What the model wrote before it reached for the tool. Anthropic puts + // this ahead of the `server_tool_use` block, and a client replaying the + // turn expects it there. + // Interleaved, exactly as the non-streaming renderer lays it out: each + // hop's prose first, then the blocks it asked for. + for (const segment of segments) { + emitText(index, 'thinking', segment.reasoning, 'thinking_delta'); + index += segment.reasoning ? 1 : 0; + emitText(index, 'text', segment.text, 'text_delta'); + index += segment.text ? 1 : 0; + + for (const execution of segment.executions) { + const toolUseId = createAnthropicId('srvtoolu'); + const [toolUse, result] = buildAnthropicServerToolBlocks(execution); + + enqueueEvent({ + type: 'content_block_start', + index, + // Anthropic builds a streamed tool input from deltas alone, so the + // block opens empty. Carrying the input here too would hand strict + // consumers the arguments twice. + content_block: { ...toolUse, id: toolUseId, input: {} }, + }); + enqueueEvent({ + type: 'content_block_delta', + index, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(execution.input), + }, + }); + enqueueEvent({ type: 'content_block_stop', index }); + index++; + + emitBlock(index++, { ...result, tool_use_id: toolUseId }); + } + } + + // Emitted after the blocks rather than instead of them: a search + // that already ran is work the client has paid for, and it is the + // only record of what happened when the answer never arrives. + if (!response.ok) { // A rate limit has to arrive as `rate_limit_error`, or a client that // retries on that type alone will treat an exhausted quota as a // generic failure and stop retrying — so the upstream status drives // the event type even though the envelope is already streaming and // the HTTP status cannot be changed. - const message = upstreamResponse.ok - ? 'Upstream request failed' - : await getUpstreamErrorMessage(upstreamResponse).catch( - () => 'Upstream request failed', - ); + const message = await getUpstreamErrorMessage(response).catch( + () => 'Upstream request failed', + ); enqueueEvent({ type: 'error', - error: { - type: anthropicErrorType(upstreamResponse.status), - message, - }, + error: { type: anthropicErrorType(response.status), message }, }); controller.close(); return; } - const mappedResponse = mapOpenAIStreamToAnthropicSSE( - upstreamResponse, - model, - { - emitMessageStart: false, - initialContentBlockCount: 0, - messageId, - serverToolExecutions: [], - }, - ); + // A buffered answer has to be replayed as SSE: the client asked to + // stream, and the turn spent the response reading the tool calls. + const upstream = isEventStream(response) + ? response + : synthesizeChatCompletionStream( + (await response.json()) as ChatCompletionPayload, + model, + ); + + if (cancelled) { + await upstream.body?.cancel(); + return; + } + + const mappedResponse = mapOpenAIStreamToAnthropicSSE(upstream, model, { + emitMessageStart: false, + initialContentBlockCount: index, + messageId, + serverToolExecutions: executions, + }); const reader = mappedResponse.body!.getReader(); activeReader = reader; diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 02f3476..7d1dbde 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -14,12 +14,7 @@ import { mapResponsesPayloadToChat, mapResponsesStreamToChat, } from './codebuddy/responses-response'; -import { detectServerToolStream } from './codebuddy/server-tools'; -import { - SERVER_WEB_TOOL_NAMES, - type ChatRequestBody, - type ProxyContext, -} from './codebuddy/types'; +import type { ChatRequestBody, ProxyContext } from './codebuddy/types'; import { extractResponsesId, extractResponsesUsage, @@ -45,165 +40,27 @@ import { getCodeBuddyApiEndpoint, getDefaultModel, } from '../domain/config'; -import { withCodeBuddyToken } from '../search/token'; -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../search/tool'; import { createErrorResponse } from '../shared/http'; import { fetchWithDeadline } from '../shared/upstream-timeout'; -import { - attachServerToolExecutions, - attachServerToolTurns, - type ChatCompletionPayload, - executeWebSearchLoop, - type ServerToolCallbacks, - synthesizeChatCompletionStream, -} from './web-search-loop'; /** - * One round trip to `/v2/chat/completions`. Split out from - * `proxyChatCompletions` so the local web search loop can re-issue the request - * with tool results appended without re-deriving auth, headers, or usage - * recording on each iteration. + * One round trip to `/v2/chat/completions`. * * Upstream is always asked to stream; `stream` only controls the shape handed * back to the caller, so it must echo what the client asked for rather than * being read off `upstreamBody`. + * + * Server tools are not handled here. The routes that support them drive this + * function themselves — they need to see the tool calls before deciding whether + * to ask again — and a request with no server tool declared never reaches the + * question. */ -const fetchChatCompletion = async ({ - body, - debugTrace, - request, - resolvedContext, - stream, - upstreamBody: providedUpstreamBody, - usageRoute, -}: { - body: ChatRequestBody; - debugTrace?: DebugTrace; - request: NextRequest; - resolvedContext: ProxyContext; - /** - * Whether the caller wants an SSE stream back. Upstream is always asked to - * stream regardless — it rejects `stream: false` with code 11101 — so this - * only chooses between passing the stream through and buffering it into a - * single JSON payload. - */ - stream: boolean; - upstreamBody?: ChatRequestBody; - usageRoute: string; -}): Promise => { - const apiEndpoint = await getCodeBuddyApiEndpoint(); - const upstreamUrl = `${apiEndpoint}/v2/chat/completions`; - const upstreamHeaders = await buildUpstreamHeaders( - request, - resolvedContext.auth, - ); - const upstreamBody = - providedUpstreamBody ?? (await buildUpstreamBody(body, resolvedContext)); - - setDebugUpstreamRequest(debugTrace, { - body: upstreamBody, - headers: headersToRecord(upstreamHeaders), - method: 'POST', - url: upstreamUrl, - }); - - const upstream = await fetchWithDeadline({ - body: JSON.stringify(upstreamBody), - headers: upstreamHeaders, - onTimeout: (error) => setDebugTraceError(debugTrace, error), - timeoutMs: await getApiFirstDeltaTimeoutMs(), - url: upstreamUrl, - }); - - if (!upstream.ok) { - return upstream.response; - } - - const upstreamResponse = enqueueUpstreamResponseSnapshot( - debugTrace, - upstream.response, - ); - - if (!upstreamResponse.ok) { - const detail = await upstreamResponse.text(); - logUpstreamFailure({ - detail, - route: '/v1/chat/completions', - status: upstreamResponse.status, - url: upstreamUrl, - }); - setDebugTraceError(debugTrace, detail); - return createErrorResponse( - upstreamResponse.status, - 'Upstream CodeBuddy request failed', - detail, - ); - } - - const contentType = upstreamResponse.headers.get('content-type') ?? ''; - - if (stream && !contentType.toLowerCase().includes('application/json')) { - return normalizeStreamingResponse({ - model: String(upstreamBody.model ?? 'unknown'), - proxyContext: resolvedContext, - route: usageRoute, - upstreamResponse, - }); - } - - // Upstream only accepts `stream: true`, so a non-streaming caller is served - // by buffering the SSE response and folding it into a single JSON payload. - if (contentType.toLowerCase().includes('application/json')) { - const payloadText = await upstreamResponse.text(); - let usage: unknown = null; - - try { - usage = (JSON.parse(payloadText) as { usage?: unknown }).usage ?? null; - } catch { - usage = null; - } - - await recordProxyUsage({ - model: String(upstreamBody.model ?? 'unknown'), - proxyContext: resolvedContext, - route: usageRoute, - usage, - }); - - return new Response(payloadText, { - status: upstreamResponse.status, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }); - } - - const aggregated = await aggregateUpstreamStream( - upstreamResponse, - String(upstreamBody.model ?? 'unknown'), - ); - - await recordProxyUsage({ - model: aggregated.model, - proxyContext: resolvedContext, - route: usageRoute, - usage: aggregated.usage, - }); - - return aggregated.response; -}; - export const proxyChatCompletions = async ( request: NextRequest, body: ChatRequestBody, context?: ProxyContext, debugTrace?: DebugTrace, usageRoute = '/v1/chat/completions', - serverToolCallbacks?: ServerToolCallbacks, ): Promise => { if (!body.messages?.length) { return createErrorResponse(400, 'messages is required'); @@ -213,100 +70,7 @@ export const proxyChatCompletions = async ( const resolvedContext = context ?? (await resolveProxyContext(request, body.model)); setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename); - let upstreamBody = await buildUpstreamBody(body, resolvedContext); - - // Server-side web tools run on the chat path only. The Responses - // passthrough path forwards to CodeBuddy's own /responses endpoint, where - // re-issuing a request with a synthesized tool result would mean replaying - // the whole conversation through a different protocol for each iteration. - if (resolvedContext.preferences.upstreamProtocol === 'chat') { - const webSearch = await withCodeBuddyToken( - // The CodeBuddy backends call the agent-tool endpoints with the same - // credential as this request, so the loop is scoped to it. Resolved - // lazily: a token is only needed when a CodeBuddy backend actually runs. - () => Promise.resolve(resolvedContext.auth.bearerToken), - () => - executeWebSearchLoop({ - body: upstreamBody, - callbacks: serverToolCallbacks, - callUpstream: async (loopBody, mode) => { - const upstreamResponse = await fetchChatCompletion({ - body: loopBody, - debugTrace, - request, - resolvedContext, - // Probe the first meaningful SSE delta for streaming callers. - // Ordinary content stays live; only a server-tool call is - // buffered into a payload the execution loop can inspect. - stream: mode !== 'buffer', - // Already normalized, so pass it straight through; re-running - // buildUpstreamBody each iteration would re-apply prompt cache - // markers to the appended tool results. - upstreamBody: { ...loopBody, stream: true }, - usageRoute, - }); - - const detectedNames = - mode === 'detect-both' - ? SERVER_WEB_TOOL_NAMES - : mode === 'detect-search' - ? [normalizeToolName(WEB_SEARCH_TOOL_NAME)] - : [normalizeToolName(WEB_FETCH_TOOL_NAME)]; - - if (mode === 'stream' || mode === 'buffer') { - return upstreamResponse; - } - - return detectServerToolStream( - upstreamResponse, - String(loopBody.model ?? 'unknown'), - detectedNames, - ); - }, - detectInitialStream: Boolean(body.stream), - }), - ); - - // No tool could be executed, so the loop declined to run. Its rewritten - // `tools` still matter: unsupported server-tool declarations have been - // stripped, and continuing with them keeps the request valid upstream - // instead of forwarding a declaration it would reject. - if (webSearch && !webSearch.response) { - upstreamBody = webSearch.body; - } - - if (webSearch?.response) { - // The hop grouping rides beside the response rather than inside its - // body, so the OpenAI-shaped payload a chat client receives stays - // protocol-clean. See `attachServerToolTurns`. - const attachServerToolResult = (response: Response): Response => - attachServerToolTurns( - attachServerToolExecutions(response, webSearch.executions), - webSearch.turns, - ); - - if (!webSearch.response.ok) { - return attachServerToolResult(webSearch.response); - } - - if ( - body.stream && - !webSearch.response.headers - .get('content-type') - ?.toLowerCase() - .includes('text/event-stream') - ) { - return attachServerToolResult( - synthesizeChatCompletionStream( - (await webSearch.response.json()) as ChatCompletionPayload, - String(upstreamBody.model ?? 'unknown'), - ), - ); - } - - return attachServerToolResult(webSearch.response); - } - } + const upstreamBody = await buildUpstreamBody(body, resolvedContext); if (resolvedContext.preferences.upstreamProtocol === 'responses') { const unsupportedOptions = getUnsupportedResponsesChatOptions(body); diff --git a/lib/server/proxy/codebuddy/server-tools.ts b/lib/server/proxy/codebuddy/server-tools.ts deleted file mode 100644 index a8e6b66..0000000 --- a/lib/server/proxy/codebuddy/server-tools.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../../search/tool'; -import { aggregateUpstreamStream } from './chat-stream'; -import { type ChatStreamChunk, type StreamProbeState } from './types'; - -export const isServerWebToolName = (name: string): boolean => { - const normalized = normalizeToolName(name); - - return ( - normalized === normalizeToolName(WEB_SEARCH_TOOL_NAME) || - normalized === normalizeToolName(WEB_FETCH_TOOL_NAME) - ); -}; - -export const mergeToolName = (previous: string, incoming: string): string => { - if (!previous || incoming.startsWith(previous)) { - return incoming; - } - - if (!incoming || previous.endsWith(incoming)) { - return previous; - } - - return previous + incoming; -}; - -export const classifyStreamFrame = ( - frame: string, - state: StreamProbeState, - serverToolNames: string[], -): 'passthrough' | 'server-tool' | null => { - const line = frame - .split('\n') - .find((segment) => segment.startsWith('data: ')); - - if (!line) { - return null; - } - - const raw = line.slice(6).trim(); - - if (!raw || raw === '[DONE]') { - return raw === '[DONE]' ? 'passthrough' : null; - } - - try { - const chunk = JSON.parse(raw) as ChatStreamChunk; - - for (const choice of chunk.choices ?? []) { - const delta = choice.delta; - const toolCalls = delta?.tool_calls ?? []; - let hasNonServerTool = false; - - for (const [position, toolCall] of toolCalls.entries()) { - const incoming = toolCall.function?.name; - - if (typeof incoming !== 'string' || !incoming) { - continue; - } - - const key = - typeof toolCall.index === 'number' - ? `index:${toolCall.index}` - : toolCall.id - ? `id:${toolCall.id}` - : `position:${position}`; - const name = mergeToolName(state.toolNames.get(key) ?? '', incoming); - const normalized = normalizeToolName(name); - - state.toolNames.set(key, name); - - if (isServerWebToolName(name) && serverToolNames.includes(normalized)) { - return 'server-tool'; - } - - if ( - normalized && - !serverToolNames.some((serverName) => - serverName.startsWith(normalized), - ) - ) { - hasNonServerTool = true; - } - } - - if ( - delta?.content || - delta?.reasoning_content || - delta?.reasoning || - hasNonServerTool || - choice.finish_reason != null - ) { - return 'passthrough'; - } - } - } catch { - return null; - } - - return null; -}; - -export const concatenateChunks = (chunks: Uint8Array[]): ArrayBuffer => { - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const combined = new Uint8Array(size); - let offset = 0; - - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - - return combined.buffer; -}; - -export const createResponseWithBody = ( - body: BodyInit, - response: Response, -): Response => - new Response(body, { - headers: response.headers, - status: response.status, - statusText: response.statusText, - }); - -export const createReplayStreamResponse = ({ - chunks, - reader, - response, -}: { - chunks: Uint8Array[]; - reader: ReadableStreamDefaultReader; - response: Response; -}): Response => { - let cancelled = false; - - const stream = new ReadableStream({ - start: (controller) => { - for (const chunk of chunks) { - controller.enqueue(chunk); - } - - const pump = async (): Promise => { - while (true) { - const { done, value } = await reader.read(); - - if (cancelled) { - return; - } - - if (done) { - reader.releaseLock(); - controller.close(); - return; - } - - controller.enqueue(value); - } - }; - - void pump().catch((error) => { - if (!cancelled) { - controller.error(error); - } - }); - }, - async cancel(reason): Promise { - cancelled = true; - try { - await reader.cancel(reason); - } finally { - reader.releaseLock(); - } - }, - }); - - return createResponseWithBody(stream, response); -}; - -export const detectServerToolStream = async ( - response: Response, - fallbackModel: string, - serverToolNames: string[], -): Promise => { - if (!response.body) { - return response; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const chunks: Uint8Array[] = []; - const state: StreamProbeState = { toolNames: new Map() }; - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - - if (done) { - reader.releaseLock(); - return createResponseWithBody(concatenateChunks(chunks), response); - } - - chunks.push(value); - buffer += decoder.decode(value, { stream: true }); - const frames = buffer.split('\n\n'); - buffer = frames.pop() ?? ''; - - for (const frame of frames) { - const classification = classifyStreamFrame(frame, state, serverToolNames); - - if (classification === 'passthrough') { - return createReplayStreamResponse({ chunks, reader, response }); - } - - if (classification === 'server-tool') { - while (true) { - const remainder = await reader.read(); - - if (remainder.done) { - reader.releaseLock(); - break; - } - - chunks.push(remainder.value); - } - - return ( - await aggregateUpstreamStream( - new Response(concatenateChunks(chunks)), - fallbackModel, - ) - ).response; - } - } - } -}; diff --git a/lib/server/proxy/codebuddy/types.ts b/lib/server/proxy/codebuddy/types.ts index c79a1a9..00682e9 100644 --- a/lib/server/proxy/codebuddy/types.ts +++ b/lib/server/proxy/codebuddy/types.ts @@ -1,9 +1,3 @@ -import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../../search/tool'; - /** * Chat completions are called from browser-side clients as well as servers, so * this route's streams carry the CORS header the other protocols do not need. @@ -126,12 +120,3 @@ export interface DiscoveredModel { displayName: string; id: string; } - -export const SERVER_WEB_TOOL_NAMES = [ - normalizeToolName(WEB_SEARCH_TOOL_NAME), - normalizeToolName(WEB_FETCH_TOOL_NAME), -]; - -export interface StreamProbeState { - toolNames: Map; -} diff --git a/lib/server/proxy/image-generation.ts b/lib/server/proxy/image-generation.ts index a6d473e..7cc7682 100644 --- a/lib/server/proxy/image-generation.ts +++ b/lib/server/proxy/image-generation.ts @@ -23,13 +23,14 @@ import { getCodeBuddyApiEndpoint } from '../domain/config'; import type { ProxyContext } from './codebuddy'; import { buildUpstreamHeaders } from './codebuddy'; import { + foldIntermediateTexts, getServerToolExecutions, - withIntermediateTurns, + getServerToolFollowUpMessages, type ChatCompletionMessage, type ChatCompletionPayload, type ChatCompletionToolCall, type ServerToolExecution, -} from './web-search-loop'; +} from './server-tools'; export const IMAGE_GENERATION_TOOL_TYPE = 'image_generation'; @@ -447,6 +448,12 @@ export const executeImageGenerationLoop = async ({ // rebuilt response the caller can no longer look them up on. serverToolExecutions.push(...getServerToolExecutions(response)); + // Likewise the messages the server-tool turn appended to its own + // transcript. It ran its searches against a transcript it built internally + // and never handed back, so a loop that replays the request would ask the + // model to continue from input in which those searches do not exist. + const followUpMessages = getServerToolFollowUpMessages(response); + // A stream has already begun emitting to the client, so it cannot be // resumed with a tool result; hand it back untouched. if ( @@ -489,12 +496,7 @@ export const executeImageGenerationLoop = async ({ executions, response: rebuildResponse( response, - withIntermediateTurns({ - executions: [], - payload, - reasonings: [], - texts: intermediateTexts, - }).payload, + foldIntermediateTexts(payload, intermediateTexts), ), serverToolExecutions, }; @@ -535,6 +537,10 @@ export const executeImageGenerationLoop = async ({ ? [...currentBody.messages] : []; + // Ahead of this round's own message: the turn's hops are what came before + // it, and dropping them loses the searches that produced this round. + messages.push(...followUpMessages); + if (message) { messages.push(message); } @@ -559,12 +565,10 @@ export const executeImageGenerationLoop = async ({ executions, response: rebuildResponse( lastResponse ?? new Response(null, { status: 502 }), - withIntermediateTurns({ - executions: [], - payload: clearClosingHop(lastPayload ?? {}), - reasonings: [], - texts: intermediateTexts, - }).payload, + foldIntermediateTexts( + clearClosingHop(lastPayload ?? {}), + intermediateTexts, + ), ), serverToolExecutions, }; diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 0bd4641..eba2377 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -16,6 +16,7 @@ import type { NextRequest } from 'next/server'; import { getDefaultModel } from '../domain/config'; import { getCredentialSupportedModels } from '../domain/credentials'; import type { DebugTrace } from '../domain/debug'; +import { withCodeBuddyToken } from '../search/token'; import { createErrorResponse } from '../shared/http'; import { resolveRequestAccessKey } from './auth'; import { @@ -41,7 +42,14 @@ import { } from './responses/tools'; import { prepareTranscript } from './responses/transcript'; import type { ResponsesRequestBody } from './responses/types'; -import { getServerToolExecutions } from './web-search-loop'; +import { + getServerToolExecutions, + hasExecutableServerTool, + prepareServerToolTurn, + reconcileToolChoice, + runServerToolTurn, + type ServerToolSegment, +} from './server-tools'; export const handleResponsesRequest = async ( request: NextRequest, @@ -165,6 +173,19 @@ export const handleResponsesRequest = async ( ); } + const translatedTools = translateResponsesToolsToChat( + prepared.defaults.tools, + ); + + // Classified on the translated tools, which keep a provider-executed + // declaration's type. A client's own function — including one named + // `web_search` — arrives as `function` and stays the client's to resolve. + const serverTools = await prepareServerToolTurn(translatedTools); + const rewrite = serverTools?.rewrite ?? null; + const willRunServerTool = Boolean( + rewrite && hasExecutableServerTool(rewrite.executable), + ); + const chatBody = { model: prepared.model, messages: [ @@ -178,13 +199,79 @@ export const handleResponsesRequest = async ( ], max_tokens: body.max_output_tokens, stream: false, - tools: translateResponsesToolsToChat(prepared.defaults.tools), - tool_choice: translateResponsesToolChoiceToChatWithTools( - prepared.defaults.tools, - prepared.defaults.tool_choice, + // Rewritten even when nothing will be executed: upstream has no server + // tools, so a declared type would be a shape it rejects. + tools: rewrite ? rewrite.tools : translatedTools, + // A server tool nothing here can run is withdrawn from `tools`, so a + // choice forcing it has to go too. + tool_choice: reconcileToolChoice( + translateResponsesToolChoiceToChatWithTools( + prepared.defaults.tools, + prepared.defaults.tool_choice, + ), + rewrite ? rewrite.tools : translatedTools, ), }; + /** + * One hop upstream, running any server tool the model asks for on the way. + * + * Both branches are needed because the turn only exists when something is + * executable; otherwise the request goes upstream as it stands, with every + * tool call coming back to the client. + */ + // What the model wrote before its first search. The image loop drives + // upstream through `callUpstream`, so the preamble has to be captured + // here rather than at a single call site. + let turnSegments: ServerToolSegment[] | undefined; + + const callUpstream = async ( + loopBody: Record, + stream: boolean, + ): Promise => { + if (!willRunServerTool || !rewrite) { + return proxyChatCompletions( + request, + { ...loopBody, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ); + } + + const outcome = await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: loopBody as never, + callUpstream: (turnBody, turnStream) => + proxyChatCompletions( + request, + { ...turnBody, stream: turnStream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + fetchProvider: serverTools!.providers.fetchProvider, + rewrite, + searchProvider: serverTools!.providers.searchProvider, + signal: request.signal, + }), + ); + + // Accumulated across iterations: the image loop calls this once per + // iteration and each outcome carries only that iteration's segments, so + // first-wins would drop every search after the first. An iteration that + // ran no server tool contributes an empty array and erases nothing, and + // `undefined` still means none ran at all — the mapper falls back to + // `serverToolExecutions` on that distinction. + if (outcome.segments.length) { + turnSegments = [...(turnSegments ?? []), ...outcome.segments]; + } + + return outcome.response; + }; + // Image generation has no chat-protocol equivalent, so the model's call is // executed here and replayed with the image folded in. Only meaningful when // the tool was actually declared; otherwise the loop returns null and the @@ -196,14 +283,9 @@ export const handleResponsesRequest = async ( serverToolExecutions, } = await executeImageGenerationLoop({ body: chatBody, - callUpstream: (loopBody) => - proxyChatCompletions( - request, - loopBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ), + // Buffered so the tool call can be inspected before any delta reaches + // the client. Any server tool the hop asked for runs inside this call. + callUpstream: (loopBody) => callUpstream(loopBody, false), context: proxyContext, request, }); @@ -232,17 +314,13 @@ export const handleResponsesRequest = async ( // nothing is keyed under this response object any more. serverToolExecutions, executions, + undefined, + turnSegments, ), ); } - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ); + const upstreamResponse = await callUpstream(chatBody, false); if (!upstreamResponse.ok) { return upstreamResponse; @@ -252,6 +330,10 @@ export const handleResponsesRequest = async ( string, unknown >; + // Read off the response rather than returned by the call: a turn rebuilds + // the response, and the image-generation loop above drives upstream itself, + // so a returned field would have to be threaded through every layer in + // between. const serverToolExecutions = getServerToolExecutions(upstreamResponse); return Response.json( @@ -264,6 +346,9 @@ export const handleResponsesRequest = async ( prepared.previousResponseId, upstreamPayload, serverToolExecutions, + [], + undefined, + turnSegments, ), ); } catch (error) { diff --git a/lib/server/proxy/responses/event-stream.ts b/lib/server/proxy/responses/event-stream.ts index 0e3a3ba..c784bc7 100644 --- a/lib/server/proxy/responses/event-stream.ts +++ b/lib/server/proxy/responses/event-stream.ts @@ -8,14 +8,13 @@ import type { NextRequest } from 'next/server'; -import { isWebFetchEnabled, isWebSearchEnabled } from '../../domain/config'; import type { DebugTrace } from '../../domain/debug'; +import { withCodeBuddyToken } from '../../search/token'; import { - normalizeToolName, - WEB_FETCH_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -} from '../../search/tool'; -import { createSseResponse, encodeDoneFrame } from '../../shared/sse'; + createSseResponse, + encodeDoneFrame, + isEventStream, +} from '../../shared/sse'; import { proxyChatCompletions, type ProxyContext } from '../codebuddy'; import { executeImageGenerationLoop } from '../image-generation'; import { @@ -23,6 +22,7 @@ import { mapChatResponseToResponsesStream, } from './payload'; import { createResponseId } from './ids'; +import { getUpstreamErrorMessage } from '../anthropic/errors'; import { mapChatStreamToResponsesEventStream } from './stream'; import { @@ -36,6 +36,13 @@ import type { ResponseSessionDefaults, TranscriptMessage, } from './types'; +import type { ServerToolExecution, ServerToolSegment } from '../server-tools'; +import { + hasExecutableServerTool, + prepareServerToolTurn, + reconcileToolChoice, + runServerToolTurn, +} from '../server-tools'; export const createResponsesEventStream = async ( request: NextRequest, @@ -47,22 +54,20 @@ export const createResponsesEventStream = async ( proxyContext: ProxyContext, debugTrace?: DebugTrace, ): Promise => { + // The image loop drives upstream through `callUpstream`, so prose written + // before a search has to be captured there rather than at one call site. + let streamSegments: ServerToolSegment[] | undefined; + const translatedTools = translateResponsesToolsToChat(defaults.tools); - const translatedToolNames = new Set( - ( - (translatedTools ?? []) as Array<{ - function: { name: string }; - }> - ).map((tool) => normalizeToolName(tool.function.name)), + + // Classified on the translated tools, which keep a provider-executed + // declaration's type. A client's own function — including one named + // `web_search` — arrives as `function` and is left to the client. + const prepared = await prepareServerToolTurn(translatedTools); + const rewrite = prepared?.rewrite ?? null; + const willRunServerTool = Boolean( + rewrite && hasExecutableServerTool(rewrite.executable), ); - const [searchEnabled, fetchEnabled] = await Promise.all([ - translatedToolNames.has(normalizeToolName(WEB_SEARCH_TOOL_NAME)) - ? isWebSearchEnabled() - : false, - translatedToolNames.has(normalizeToolName(WEB_FETCH_TOOL_NAME)) - ? isWebFetchEnabled() - : false, - ]); const chatBody = { model, @@ -74,13 +79,74 @@ export const createResponsesEventStream = async ( ], max_tokens: maxOutputTokens, stream: true, - tools: translatedTools, - tool_choice: translateResponsesToolChoiceToChatWithTools( - defaults.tools, - defaults.tool_choice, + // Rewritten even when nothing will be executed: upstream has no server + // tools, so a declared type would be a shape it rejects. + tools: rewrite ? rewrite.tools : translatedTools, + // A server tool nothing here can run is withdrawn from `tools`, so a + // choice forcing it has to go too. + tool_choice: reconcileToolChoice( + translateResponsesToolChoiceToChatWithTools( + defaults.tools, + defaults.tool_choice, + ), + rewrite ? rewrite.tools : translatedTools, ), }; + /** + * One hop upstream, running any server tool the model asks for on the way. + * + * The image loop drives upstream itself, so the turn has to be reachable from + * here too — a hop can ask for an image and a search at once, and the search + * still has to run. + */ + const callUpstream = async ( + loopBody: Record, + stream: boolean, + ): Promise => { + if (!willRunServerTool || !rewrite) { + return proxyChatCompletions( + request, + { ...loopBody, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ); + } + + const outcome = await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + body: loopBody as never, + callUpstream: (turnBody, turnStream) => + proxyChatCompletions( + request, + { ...turnBody, stream: turnStream } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + fetchProvider: prepared!.providers.fetchProvider, + rewrite, + searchProvider: prepared!.providers.searchProvider, + signal: request.signal, + }), + ); + + // Accumulated across iterations: the image loop calls this once per + // iteration and each outcome carries only that iteration's segments, so + // first-wins would drop every search after the first. An iteration that ran + // no server tool contributes an empty array and erases nothing, and + // `undefined` still means none ran at all — the mapper falls back to + // `serverToolExecutions` on that distinction. + if (outcome.segments.length) { + streamSegments = [...(streamSegments ?? []), ...outcome.segments]; + } + + return outcome.response; + }; + // Image generation is executed locally, so a streaming request has to be // buffered first to see whether the model asked for an image. Without this // the call is forwarded as an ordinary function_call the client is expected @@ -94,15 +160,10 @@ export const createResponsesEventStream = async ( await executeImageGenerationLoop({ body: chatBody, // Buffered so the tool call can be inspected before any delta reaches - // the client; the ordinary path below stays live. - callUpstream: (loopBody) => - proxyChatCompletions( - request, - { ...loopBody, stream: false } as never, - proxyContext, - debugTrace, - '/v1/responses', - ), + // the client; the ordinary path below stays live. Any server tool the + // hop asked for runs inside this call, and its lifecycle is replayed + // from `serverToolExecutions` rather than announced live. + callUpstream: (loopBody) => callUpstream(loopBody, false), context: proxyContext, request, }); @@ -123,20 +184,22 @@ export const createResponsesEventStream = async ( proxyContext, executions, serverToolExecutions, + undefined, + streamSegments, ); } - if (!searchEnabled && !fetchEnabled) { - const upstreamResponse = await proxyChatCompletions( - request, - chatBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ); - + // Nothing local to run: the request goes upstream as it stands and every tool + // call comes back to the client. + if (!willRunServerTool) { return mapChatStreamToResponsesEventStream( - upstreamResponse, + await proxyChatCompletions( + request, + chatBody as never, + proxyContext, + debugTrace, + '/v1/responses', + ), defaults, transcript, model, @@ -147,13 +210,36 @@ export const createResponsesEventStream = async ( const encoder = new TextEncoder(); const responseId = createResponseId(); - const serverToolItems: ResponsesServerToolItem[] = []; - const itemsByInvocationId = new Map(); let nextOutputIndex = 0; const allocateOutputIndex = (): number => nextOutputIndex++; let activeReader: ReadableStreamDefaultReader | null = null; let cancelled = false; + /** + * `web_search_call` items for the searches a turn already ran. + * + * Nothing announces them live — the turn is buffered throughout, since + * whether the model wants another search is only knowable once a hop has + * finished — so the mapper emits the whole lifecycle in one pass when the + * answer is replayed. + */ + const buildSearchItems = ( + executions: ServerToolExecution[], + ): ResponsesServerToolItem[] => + executions.map((execution) => { + const id = `ws_${crypto.randomUUID().replaceAll('-', '')}`; + + return { + completed: buildResponsesWebSearchCallItem(execution, 'completed', id), + inProgress: buildResponsesWebSearchCallItem( + execution, + 'in_progress', + id, + ), + outputIndex: allocateOutputIndex(), + }; + }); + const stream = new ReadableStream({ start: (controller) => { const enqueueEvent = ( @@ -167,6 +253,10 @@ export const createResponsesEventStream = async ( ); }; + // Announced now, under the id the replay will reuse. The turn is + // buffered throughout, so without this the client would see nothing + // until every search and every hop had finished — long enough that a + // client with an idle timeout would drop the connection. enqueueEvent({ type: 'response.created', response: { @@ -175,6 +265,7 @@ export const createResponsesEventStream = async ( created_at: Math.floor(Date.now() / 1000), model, output: [], + status: 'in_progress', }, }); enqueueEvent({ @@ -183,119 +274,94 @@ export const createResponsesEventStream = async ( }); const run = async (): Promise => { - const upstreamResponse = await proxyChatCompletions( - request, - { - model, - messages: [ - ...(defaults.instructions - ? [{ role: 'system', content: defaults.instructions }] - : []), - ...normalizeTranscriptMessageToolNames( - transcript, - defaults.tools, - ), - ], - max_tokens: maxOutputTokens, - stream: true, - tools: translatedTools, - tool_choice: translateResponsesToolChoiceToChatWithTools( - defaults.tools, - defaults.tool_choice, - ), - }, - proxyContext, - debugTrace, - '/v1/responses', - { - emitStreamEvents: true, - onCall: (invocation) => { - const outputIndex = allocateOutputIndex(); - const id = `ws_${crypto.randomUUID().replaceAll('-', '')}`; - const item = { - completed: buildResponsesWebSearchCallItem( - invocation, - 'completed', - id, - ), - inProgress: buildResponsesWebSearchCallItem( - invocation, - 'in_progress', - id, + const { fetchProvider, searchProvider } = prepared!.providers; + + const { executions, response, segments } = await withCodeBuddyToken( + () => Promise.resolve(proxyContext.auth.bearerToken), + () => + runServerToolTurn({ + // No onCall/onResult: the turn is buffered, so the lifecycle is + // replayed from `executions` in one consistent pass instead of + // being emitted live and then again by the replay. + body: chatBody as never, + callUpstream: (body, stream) => + proxyChatCompletions( + request, + { ...body, stream } as never, + proxyContext, + debugTrace, + '/v1/responses', ), - outputIndex, - }; - serverToolItems.push(item); - itemsByInvocationId.set(invocation.id, item); - enqueueEvent({ - type: 'response.output_item.added', - item: item.inProgress, - output_index: outputIndex, - response_id: responseId, - }); - enqueueEvent({ - type: 'response.web_search_call.in_progress', - item_id: id, - output_index: outputIndex, - }); - enqueueEvent({ - type: 'response.web_search_call.searching', - item_id: id, - output_index: outputIndex, - }); - }, - onResult: (execution) => { - const item = itemsByInvocationId.get(execution.id)!; - const id = String(item.inProgress.id); - item.completed = buildResponsesWebSearchCallItem( - execution, - 'completed', - id, - ); - enqueueEvent({ - type: 'response.web_search_call.completed', - item_id: id, - output_index: item.outputIndex, - }); - enqueueEvent({ - type: 'response.output_item.done', - item: item.completed, - output_index: item.outputIndex, - response_id: responseId, - }); - }, - }, + fetchProvider, + rewrite: rewrite!, + searchProvider, + signal: request.signal, + }), ); if (cancelled) { - await upstreamResponse.body?.cancel(); + await response.body?.cancel(); return; } - if (!upstreamResponse.ok || !upstreamResponse.body) { + if (!response.ok) { + // The upstream's own words: a rate limit has to arrive as one, or a + // client that retries on that alone stops retrying. enqueueEvent({ type: 'response.error', - error: { message: 'Upstream request failed' }, + error: { + message: await getUpstreamErrorMessage(response).catch( + () => 'Upstream request failed', + ), + }, }); controller.enqueue(encodeDoneFrame()); controller.close(); return; } - const mappedResponse = mapChatStreamToResponsesEventStream( - upstreamResponse, - defaults, - transcript, - model, - previousResponseId, - proxyContext, - responseId, - serverToolItems, - false, - false, - allocateOutputIndex, - true, - ); + /** + * The turn is finished before this point, so `response` is a buffered + * payload, not a live stream — every hop had to complete to know + * whether the model wanted another search. Handing that to the SSE + * mapper would find no `data:` frames and drop the answer entirely, so + * a buffered response is replayed through the buffered→Responses + * mapper instead. + */ + const mappedResponse = await (isEventStream(response) + ? mapChatStreamToResponsesEventStream( + response, + defaults, + transcript, + model, + previousResponseId, + proxyContext, + responseId, + buildSearchItems(executions), + false, + true, + allocateOutputIndex, + true, + ) + : mapChatResponseToResponsesStream( + (await response.json()) as Record, + defaults, + transcript, + model, + previousResponseId, + proxyContext, + [], + executions, + // The id already announced to the client. The mapper persists + // the session under whatever id it emits, so without this the + // client is handed an id nothing was stored against, and a + // follow-up carrying `previous_response_id` fails. + responseId, + segments, + // Already announced above: the replay must not emit a + // second `response.created` under the same id. + false, + )); const reader = mappedResponse.body!.getReader(); activeReader = reader; diff --git a/lib/server/proxy/responses/payload.ts b/lib/server/proxy/responses/payload.ts index c738d54..25a8362 100644 --- a/lib/server/proxy/responses/payload.ts +++ b/lib/server/proxy/responses/payload.ts @@ -9,6 +9,7 @@ import { eventFrameText, } from '../../shared/sse'; import type { ProxyContext } from '../codebuddy'; +import type { WebSearchResult } from '../../search/types'; import { buildResponsesImageGenerationCallItem, type ImageGenerationExecution, @@ -36,7 +37,82 @@ import type { import type { ServerToolExecution, ServerToolInvocation, -} from '../web-search-loop'; + ServerToolSegment, +} from '../server-tools'; + +/** A span of `output_text` that points at one search result. */ +interface UrlCitationSpan { + end: number; + start: number; + title: string; + url: string; +} + +/** + * The `url_citation` annotations for one `output_text`. + * + * Only URLs the model actually wrote into the text are annotated, because no + * index here can be derived: a search response carries titles and URLs but no + * offsets into an answer that does not exist yet, and the upstream chat + * protocol hands the answer back as a single opaque string — so choosing a + * span for a result would mean inventing one. A model that cites a source + * normally quotes its URL, and that occurrence is a span measured rather than + * guessed. A result whose URL never appears in the text gets no annotation. + */ +const buildUrlCitationAnnotations = ( + text: string, + results: WebSearchResult[], +): Array> => { + const claimed = new Set(); + + const spans = results.flatMap((result): UrlCitationSpan[] => { + const url = result.url ?? ''; + + // Two results can share a URL; a second span over identical offsets would + // overlap the first by definition. + if (!url || claimed.has(url)) { + return []; + } + + claimed.add(url); + const title = result.title || url; + const found: UrlCitationSpan[] = []; + + for ( + let start = text.indexOf(url); + start !== -1; + start = text.indexOf(url, start + url.length) + ) { + found.push({ end: start + url.length, start, title, url }); + } + + return found; + }); + + // Longest first at one offset: a result URL that prefixes another would + // otherwise take the shorter span and leave the longer one overlapping. + spans.sort((left, right) => left.start - right.start || right.end - left.end); + + const annotations: Array> = []; + let coveredUntil = 0; + + spans.forEach(({ end, start, title, url }) => { + if (start < coveredUntil) { + return; + } + + coveredUntil = end; + annotations.push({ + type: 'url_citation', + start_index: start, + end_index: end, + title, + url, + }); + }); + + return annotations; +}; export const mapChatResponseToResponsesPayload = async ( accessKeyId: string | null, @@ -48,8 +124,10 @@ export const mapChatResponseToResponsesPayload = async ( upstreamPayload: Record, serverToolExecutions: ServerToolExecution[], imageExecutions: ImageGenerationExecution[] = [], + pinnedResponseId?: string, + segments?: ServerToolSegment[], ): Promise> => { - const responseId = createResponseId(); + const responseId = pinnedResponseId ?? createResponseId(); const choices = Array.isArray(upstreamPayload.choices) ? upstreamPayload.choices : []; @@ -61,10 +139,52 @@ export const mapChatResponseToResponsesPayload = async ( : []; const outputText = stringifyContent(firstChoice.message?.content); const createdAt = Math.floor(Date.now() / 1000); + // What the model said before it reached for a search, ahead of the searches + // themselves — the order it was written in. Only the closing hop's prose and + // reasoning live in `upstreamPayload`, so without this a Responses client + // never sees the first half of the turn. + // Interleaved, matching the Anthropic renderer: each hop's prose, then the + // calls it asked for. A single leading preamble would put prose written + // between two searches before both of them. + const segmentItems: Array> = segments + ? segments.flatMap((segment) => [ + ...(segment.reasoning + ? [ + { + id: createResponseReasoningId(), + type: 'reasoning', + summary: [{ type: 'summary_text', text: segment.reasoning }], + encrypted_content: `${REASONING_PREFIX}${segment.reasoning}`, + status: 'completed', + }, + ] + : []), + ...(segment.text + ? [ + { + id: createMessageId(), + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: segment.text, annotations: [] }, + ], + }, + ] + : []), + ...segment.executions.map((execution) => + buildResponsesWebSearchCallItem(execution, 'completed'), + ), + ]) + : []; + const output: Array> = [ - ...serverToolExecutions.map((execution) => - buildResponsesWebSearchCallItem(execution, 'completed'), - ), + ...segmentItems, + ...(segments + ? [] + : serverToolExecutions.map((execution) => + buildResponsesWebSearchCallItem(execution, 'completed'), + )), // Image generation is executed locally, so the standard // `image_generation_call` item has to be synthesized here — the chat // upstream has no notion of it. @@ -103,6 +223,17 @@ export const mapChatResponseToResponsesPayload = async ( }); } + // Exactly the searches this response reports. Only those may be cited: a + // result the client saw no `web_search_call` for is not a source it can + // trace the citation back to. + const reportedExecutions = segments + ? segments.flatMap((segment) => segment.executions) + : serverToolExecutions; + + const citedResults = reportedExecutions.flatMap((execution) => + execution.type === 'web_search' ? (execution.result?.results ?? []) : [], + ); + if (outputText || !toolCalls.length) { output.push({ id: createMessageId(), @@ -113,7 +244,7 @@ export const mapChatResponseToResponsesPayload = async ( { type: 'output_text', text: outputText, - annotations: [], + annotations: buildUrlCitationAnnotations(outputText, citedResults), }, ], }); @@ -208,6 +339,9 @@ export const mapChatResponseToResponsesStream = async ( proxyContext: ProxyContext, imageExecutions: ImageGenerationExecution[], serverToolExecutions: ServerToolExecution[] = [], + pinnedResponseId?: string, + segments?: ServerToolSegment[], + emitOpeningEvents = true, ): Promise => { const payload = await mapChatResponseToResponsesPayload( proxyContext.accessKeyId, @@ -219,13 +353,22 @@ export const mapChatResponseToResponsesStream = async ( upstreamPayload, serverToolExecutions, imageExecutions, + pinnedResponseId, + segments, ); // The mapper creates and persists the session id, so the stream has to reuse // it: advertising a different one would leave a client unable to continue the // turn, because nothing was stored under the id it was given. const responseId = String(payload.id); const output = payload.output as Array>; - const messageIndex = output.findIndex((item) => item.type === 'message'); + // The last message, not the first: a segment's prose is a message too, and + // streaming the pre-search prose as the answer would drop the real one. + let messageIndex = -1; + output.forEach((item, index) => { + if (item.type === 'message') { + messageIndex = index; + } + }); const messageItem = messageIndex === -1 ? null @@ -288,14 +431,21 @@ export const mapChatResponseToResponsesStream = async ( }; const frames: Array> = [ - { - response: { ...payload, output: [], status: 'in_progress' }, - type: 'response.created', - }, - { - response: { id: responseId, status: 'in_progress' }, - type: 'response.in_progress', - }, + // Skipped when the caller already announced the opening: a streaming + // server-tool turn emits it up front so the connection is not idle for + // the whole turn, and a second copy would give the client two ids. + ...(emitOpeningEvents + ? [ + { + response: { ...payload, output: [], status: 'in_progress' }, + type: 'response.created', + }, + { + response: { id: responseId, status: 'in_progress' }, + type: 'response.in_progress', + }, + ] + : []), ...otherItems.flatMap(({ item, output_index }) => serverToolFrames({ item, output_index }), ), diff --git a/lib/server/proxy/responses/stream.ts b/lib/server/proxy/responses/stream.ts index 1e490db..5636936 100644 --- a/lib/server/proxy/responses/stream.ts +++ b/lib/server/proxy/responses/stream.ts @@ -41,7 +41,7 @@ import type { StreamingToolCallState, TranscriptMessage, } from './types'; -import { getServerToolExecutions } from '../web-search-loop'; +import { getServerToolExecutions } from '../server-tools'; import { MAX_RESPONSE_SESSION_TOTAL_BYTES } from './session'; const MAX_STREAM_BUFFER_LENGTH = 1_000_000; @@ -203,6 +203,7 @@ export const mapChatStreamToResponsesEventStream = ( created_at: Math.floor(Date.now() / 1000), model, output: [], + status: 'in_progress', }, }); enqueueEvent({ diff --git a/lib/server/proxy/responses/tools.ts b/lib/server/proxy/responses/tools.ts index 0124d22..80e4d06 100644 --- a/lib/server/proxy/responses/tools.ts +++ b/lib/server/proxy/responses/tools.ts @@ -6,7 +6,6 @@ import { createErrorResponse } from '../../shared/http'; import { buildWebFetchToolDefinition, buildWebSearchToolDefinition, - markServerTool, normalizeToolName, WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX, @@ -18,6 +17,10 @@ import { IMAGE_GENERATION_CHAT_TOOL_NAME, IMAGE_GENERATION_TOOL_TYPE, } from '../image-generation'; +import { + classifyServerToolDeclaration, + type ServerToolKind, +} from '../server-tools'; import type { ResponsesRequestBody, SupportedChatTool, @@ -129,9 +132,13 @@ export const toSupportedChatTool = ( const toolType = typeof tool.type === 'string' ? tool.type : 'function'; // Server-side search and fetch carry no function schema, so the generic - // branch below drops them. Emit them as functions unconditionally and let - // the proxy loop resolve the configured backend asynchronously. SearXNG - // needs local configuration, while CodeBuddy search does not. + // branch below drops them. Emit them as functions unconditionally and let the + // route resolve the configured backend asynchronously. SearXNG needs local + // configuration, while CodeBuddy search does not. + // + // The declared type is kept, not flattened: it is the only thing that + // distinguishes a provider-executed tool from the client's own function, and + // a client's own `web_search` must stay the client's to resolve. if ( normalizeToolName(toolType).startsWith( normalizeToolName(WEB_SEARCH_TOOL_TYPE_PREFIX), @@ -142,9 +149,10 @@ export const toSupportedChatTool = ( return [ { chatName: WEB_SEARCH_TOOL_NAME, + declaration: tool as unknown as Record, kind: 'function', originalName: WEB_SEARCH_TOOL_NAME, - serverDeclared: true, + serverType: toolType, tool: definition, }, ]; @@ -163,9 +171,10 @@ export const toSupportedChatTool = ( return [ { chatName: WEB_FETCH_TOOL_NAME, + declaration: tool as unknown as Record, kind: 'function', originalName: WEB_FETCH_TOOL_NAME, - serverDeclared: true, + serverType: toolType, tool: definition, }, ]; @@ -181,7 +190,6 @@ export const toSupportedChatTool = ( chatName: IMAGE_GENERATION_CHAT_TOOL_NAME, kind: 'function' as const, originalName: IMAGE_GENERATION_TOOL_TYPE, - serverDeclared: true, tool: buildImageGenerationChatTool(), }, ]; @@ -388,14 +396,86 @@ export const translateResponsesToolsToChat = ( } return supported.map((tool) => { + if (!tool.serverType) { + // An ordinary function, which upstream understands as it stands. + return { type: 'function', function: tool.tool }; + } + + // A provider-executed declaration keeps its declared type — the only thing + // that tells it apart from the client's own function of the same name — and + // carries the rest of what the client declared (`max_uses`, + // `allowed_domains`, …), which the turn reads before it rewrites the shape. return { - type: 'function', + ...(tool.declaration ?? {}), + type: tool.serverType, function: tool.tool, - ...(tool.serverDeclared ? markServerTool({}) : {}), }; }); }; +/** + * The function a hosted-tool `tool_choice` has to name. + * + * The choice repeats the declared type — `web_search_preview` — and that is a + * shape upstream has never seen: the declaration was rewritten into an ordinary + * function on its way out. Naming the injected function is what lets the pin do + * its job, which is to make the model emit a query instead of answering from + * memory. + */ +const SERVER_TOOL_CHOICE_NAMES: Record = { + web_fetch: WEB_FETCH_TOOL_NAME, + web_search: WEB_SEARCH_TOOL_NAME, +}; + +/** + * Image generation is executed by its own loop, never by the server-tool turn, + * so it is deliberately outside the classifier's vocabulary — widening that + * would have the turn claim a call it cannot run. It is still a tool this + * adapter serves, so it gets a branch of its own everywhere one is needed. + */ +const isImageGenerationToolChoice = ( + choice: Record, +): boolean => choice.type === IMAGE_GENERATION_TOOL_TYPE; + +/** + * A pin on a hosted type the request declared but this adapter withdraws. + * + * `getSupportedChatTools` drops a hosted declaration with no implementation + * here, so the tool never reaches upstream — and a pin naming it would be a + * choice with nothing behind it, which an upstream that validates the two + * together rejects. Serving the request without the tool is the honest + * degradation: the model answers from memory, which is what + * `reconcileToolChoice` already arranges for a server tool with no backend. + */ +const isWithdrawnToolChoice = ( + tools: ResponsesRequestBody['tools'], + toolChoice: unknown, +): boolean => { + if (typeof toolChoice !== 'object' || toolChoice === null) { + return false; + } + + const choice = toolChoice as Record; + const type = typeof choice.type === 'string' ? choice.type : ''; + + // Every other shape has a branch of its own, and none of them is a + // withdrawal: a function is pinned by name, and a hosted tool this adapter + // serves is translated rather than dropped. + if ( + !type || + type === 'function' || + typeof choice.name === 'string' || + isImageGenerationToolChoice(choice) || + classifyServerToolDeclaration(choice) !== null + ) { + return false; + } + + return Boolean( + tools?.some((tool) => typeof tool?.type === 'string' && tool.type === type), + ); +}; + export const translateResponsesToolChoiceToChat = ( toolChoice: unknown, ): unknown => { @@ -422,6 +502,29 @@ export const translateResponsesToolChoiceToChat = ( return choice.type; } + // A hosted-tool choice names the same declaration the tools array carries + // under its own type, so classification recognises it. Upstream only ever + // sees the function the proxy injected, and a pin left as + // `web_search_preview` is a shape it has never heard of. + const serverToolKind = classifyServerToolDeclaration(choice); + + if (serverToolKind) { + return { + type: 'function', + function: { name: SERVER_TOOL_CHOICE_NAMES[serverToolKind] }, + }; + } + + // The image tool is rewritten into a function on its way out too, so its pin + // has to name that function for the same reason a search pin does: upstream + // has never heard of `image_generation` as a tool type. + if (isImageGenerationToolChoice(choice)) { + return { + type: 'function', + function: { name: IMAGE_GENERATION_CHAT_TOOL_NAME }, + }; + } + // Responses API selects a function by name: // {type: 'function', name: 'fn'} -> chat schema {type: 'function', function: {name: 'fn'}} if (typeof choice.name === 'string') { @@ -438,6 +541,12 @@ export const translateResponsesToolChoiceToChatWithTools = ( tools: ResponsesRequestBody['tools'], toolChoice: unknown, ): unknown => { + // A withdrawn declaration is not on offer upstream, so its pin goes rather + // than being sent as a type upstream has never heard of. + if (isWithdrawnToolChoice(tools, toolChoice)) { + return undefined; + } + const translated = translateResponsesToolChoiceToChat(toolChoice); if (typeof translated !== 'object' || translated === null) { @@ -513,11 +622,23 @@ export const getResponsesCompatibilityError = ( choice.type === 'none' || choice.type === 'required'; const isNamedFunctionLikeChoice = typeof choice.name === 'string'; + // A hosted tool is pinned by its declared type — the same vocabulary the + // tools array uses, so the classifier recognises it. Rejecting it here + // 400s a request this adapter can serve; the choice is rewritten below. + // Three cases, and none of them is a client error: search and fetch name + // the injected function, image generation names its own, and a type no + // implementation here serves is dropped rather than pinned to a tool + // upstream is never offered. + const isHostedToolChoice = + classifyServerToolDeclaration(choice) !== null || + isImageGenerationToolChoice(choice) || + isWithdrawnToolChoice(tools, choice); if ( !isPretranslatedFunctionChoice && !isSimpleChoiceType && - !isNamedFunctionLikeChoice + !isNamedFunctionLikeChoice && + !isHostedToolChoice ) { return createErrorResponse( 400, diff --git a/lib/server/proxy/responses/transcript.ts b/lib/server/proxy/responses/transcript.ts index 9744f6c..8a0c4c3 100644 --- a/lib/server/proxy/responses/transcript.ts +++ b/lib/server/proxy/responses/transcript.ts @@ -228,6 +228,18 @@ export const mapInputItemToMessage = ( }; } + // Call items the proxy itself mints and a stateless client replays verbatim + // from the previous response's `output`. They carry no text, so the + // plain-message case below would turn each one into an empty + // `{role:'user', content:''}` entry — one phantom user turn per search the + // previous turn ran, repeated on every later turn. + if ( + item.type === 'web_search_call' || + item.type === 'image_generation_call' + ) { + return null; + } + // Every other item type returns above, so what is left is a plain message: // either one with a declared `type: 'message'`, or one carrying only // `role`/`content`. Images are kept structured so the chat path can rebuild diff --git a/lib/server/proxy/responses/types.ts b/lib/server/proxy/responses/types.ts index 83632de..1c890a7 100644 --- a/lib/server/proxy/responses/types.ts +++ b/lib/server/proxy/responses/types.ts @@ -31,16 +31,25 @@ export interface SupportedChatTool { namespace?: string; originalName: string; /** - * True when the client declared this as a provider-executed server tool - * (`web_search_20260209`, `web_fetch_20250910`, `web_search_preview`) rather - * than as its own function. + * The type the client declared this tool with, when it asked the *provider* + * to run it (`web_search_preview`, `web_fetch_20250910`) rather than + * declaring a function of its own. * - * Translation turns both into ordinary functions for upstream, so without this - * the proxy cannot tell them apart later — and the difference decides whether a - * tool that cannot be executed is dropped or forwarded. + * Translation keeps that type on the chat tool instead of flattening it to + * `function`, which is what lets the proxy recognise a provider-executed + * declaration after translation. A client's own function — including one + * named `web_search` — arrives as `function` and is left to the client. */ - serverDeclared?: boolean; + serverType?: string; serverLabel?: string; + /** + * The client's declaration, untouched. + * + * `toSupportedChatTool` synthesises a fresh object, so anything the client + * set that the proxy has no field for — `max_uses`, `allowed_domains`, + * `user_location` — would otherwise vanish before the turn reads it. + */ + declaration?: Record; tool: Record; } diff --git a/lib/server/proxy/server-tool/classify.ts b/lib/server/proxy/server-tool/classify.ts deleted file mode 100644 index 8fce0ac..0000000 --- a/lib/server/proxy/server-tool/classify.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { asRecord } from '../../shared/content'; -import { - buildWebFetchToolDefinition, - buildWebSearchToolDefinition, - isMarkedServerTool, - normalizeToolName, - stripServerToolMarker, - WEB_FETCH_TOOL_NAME, - WEB_FETCH_TOOL_TYPE_PREFIX, - WEB_SEARCH_TOOL_NAME, - WEB_SEARCH_TOOL_TYPE_PREFIX, -} from '../../search/tool'; -import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; -import type { ChatCompletionToolCall } from './types'; - -/** - * Recognises one server-tool declaration. - * - * Anthropic sends dated server-tool *types* (`web_search_20260209`, - * `web_fetch_20250910`), Responses sends `web_search_preview`, and a client may - * also declare a plain function tool with the bare name for its own purposes. - * All three shapes have to match, because the tool has to be swapped for a - * function upstream can actually call regardless of how it arrived. - * - * Returns two independent answers. `matches` says the declaration is one this - * proxy can serve; `serverDeclared` says it arrived as a provider-executed - * server tool rather than as the client's own function. The difference decides - * what happens when the tool cannot be executed: a server-tool declaration is - * dropped, because upstream has no idea what to do with it, whereas the - * client's own function is left exactly as sent — the client is the one that - * resolves it, and deleting it would silently remove a capability the client - * asked for. - */ -export const classifyServerTool = ( - tool: unknown, - name: string, - prefix: string, -): { matches: boolean; serverDeclared: boolean } => { - const record = asRecord(tool); - - if (!record) { - return { matches: false, serverDeclared: false }; - } - - const type = typeof record.type === 'string' ? record.type : ''; - - // A dedicated server-tool type (`web_search_20260209`, `web_fetch_20250910`, - // `web_search_preview`) is unambiguous: only a provider-executed tool is - // declared that way. The trailing date is part of the version, not the name, - // so the prefix is matched in canonical form — `WebFetch_20250910` arrives - // from upstream as readily as its snake_case spelling. - if (normalizeToolName(type).startsWith(normalizeToolName(prefix))) { - return { matches: true, serverDeclared: true }; - } - - const fn = asRecord(record.function); - const isBareName = - (typeof fn?.name === 'string' && - normalizeToolName(fn.name) === normalizeToolName(name)) || - (typeof record.name === 'string' && - normalizeToolName(record.name).startsWith(normalizeToolName(prefix))); - - // A Responses translation has already flattened the declaration into a plain - // function, so the type is gone by now; its marker is the only surviving - // evidence that the client asked for a provider-executed tool. - return { - matches: isBareName, - serverDeclared: isBareName && isMarkedServerTool(tool), - }; -}; - -export const isWebSearchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_TYPE_PREFIX) - .matches; - -export const isWebFetchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX) - .matches; - -export const isServerDeclaredSearchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_TYPE_PREFIX) - .serverDeclared; - -export const isServerDeclaredFetchTool = (tool: unknown): boolean => - classifyServerTool(tool, WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_TYPE_PREFIX) - .serverDeclared; - -/** - * Whether `toolCall` is a call the proxy is meant to execute. - * - * Matched in canonical form because the name comes back from the model, which - * is under no obligation to repeat the spelling it was given: upstream echoes - * `web_fetch` as `WebFetch` often enough to matter here. A miss is not a - * fallback to the client — the call leaves the loop as an unanswered - * client-owned tool, so the fetch silently never happens. - */ -export const isWebSearchToolCall = ( - toolCall: ChatCompletionToolCall, -): boolean => { - return ( - typeof toolCall.function?.name === 'string' && - normalizeToolName(toolCall.function.name) === - normalizeToolName(WEB_SEARCH_TOOL_NAME) - ); -}; - -export const isWebFetchToolCall = ( - toolCall: ChatCompletionToolCall, -): boolean => { - return ( - typeof toolCall.function?.name === 'string' && - normalizeToolName(toolCall.function.name) === - normalizeToolName(WEB_FETCH_TOOL_NAME) - ); -}; - -/** - * Swaps locally executed server-tool declarations for functions upstream can - * call. Passthrough tools keep their upstream representation. - * - * Returns `null` when no web tool is present. `executes` distinguishes a local - * backend from passthrough: the latter still strips the internal provenance - * marker, but never starts the server loop or buffers a stream. - */ -export const replaceServerTools = ({ - fetchEnabled, - fetchProvider, - searchEnabled, - searchPassthrough, - searchProvider, - tools, -}: { - fetchEnabled: boolean; - fetchProvider: WebFetchProvider | null; - searchEnabled: boolean; - searchPassthrough: boolean; - searchProvider: WebSearchProvider | null; - tools: unknown; -}): { - executes: boolean; - /** Canonical names the proxy took over, so call classification can tell its own calls from a client's. */ - ownedNames: Set; - tools: unknown[]; -} | null => { - if (!Array.isArray(tools) || !tools.length) { - return null; - } - - let matched = false; - let executes = false; - // Names the proxy is executing itself. A client may declare its own tool - // under the same name, and the loop must not answer those calls: matching - // the name is not enough to own it. - const ownedNames = new Set(); - - const rewritten = tools.flatMap((tool): unknown[] => { - if (isWebSearchTool(tool)) { - if (searchEnabled && searchProvider) { - matched = true; - executes = true; - ownedNames.add(normalizeToolName(WEB_SEARCH_TOOL_NAME)); - - return [{ type: 'function', function: buildWebSearchToolDefinition() }]; - } - - if (!isServerDeclaredSearchTool(tool)) { - return [tool]; - } - - matched = true; - return searchPassthrough ? [stripServerToolMarker(tool)] : []; - } - - if (isWebFetchTool(tool)) { - // A client-owned function of the same name wins over the backend, exactly - // as it does for search. The backend setting chooses who runs the *proxy's* - // tool; it is not a licence to take over a tool the client declared and - // resolves itself. Without this, a client that ships its own `web_fetch` - // loses it the moment a deployment picks a backend. - if (!isServerDeclaredFetchTool(tool)) { - return [tool]; - } - - if (fetchEnabled && fetchProvider) { - matched = true; - executes = true; - ownedNames.add(normalizeToolName(WEB_FETCH_TOOL_NAME)); - - return [{ type: 'function', function: buildWebFetchToolDefinition() }]; - } - - matched = true; - return [stripServerToolMarker(tool)]; - } - - // The marker is internal to this proxy, so it never reaches upstream. - return [stripServerToolMarker(tool)]; - }); - - return matched ? { executes, ownedNames, tools: rewritten } : null; -}; - -/** - * Whether the proxy is the one meant to answer this call. - * - * A call without an available backend is not a fallback to the client — it - * leaves the loop as an unanswered client-owned tool — but it must not be - * counted as locally executable either. - */ -export const isLocalServerToolCall = ({ - fetchProvider, - ownedNames, - toolCall, - searchProvider, -}: { - fetchProvider: WebFetchProvider | null; - /** - * Canonical names the proxy took over. Without it a client's own tool that - * happens to share a name — `web_fetch`, which is not a server tool in the - * Responses API — gets executed by the loop instead of handed back. - */ - ownedNames?: Set; - toolCall: ChatCompletionToolCall; - searchProvider: WebSearchProvider | null; -}): boolean => - (Boolean(searchProvider) && - isWebSearchToolCall(toolCall) && - isOwned(ownedNames, WEB_SEARCH_TOOL_NAME)) || - (Boolean(fetchProvider) && - isWebFetchToolCall(toolCall) && - isOwned(ownedNames, WEB_FETCH_TOOL_NAME)); - -/** - * Whether the proxy owns calls to `name`. - * - * `undefined` means the caller predates ownership tracking; those callers only - * ever run the proxy's own declarations, so they are unaffected by client tools - * of the same name. - */ -export const isOwned = ( - ownedNames: Set | undefined, - name: string, -): boolean => !ownedNames || ownedNames.has(normalizeToolName(name)); diff --git a/lib/server/proxy/server-tool/execution.ts b/lib/server/proxy/server-tool/execution.ts deleted file mode 100644 index 839d4c8..0000000 --- a/lib/server/proxy/server-tool/execution.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { runWebFetchResult, runWebSearchResult } from '../../search'; -import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; -import { asRecord } from '../../shared/content'; -import { extractFetchQuery, extractSearchQuery } from './args'; -import { isWebFetchToolCall } from './classify'; -import { - SERVER_TOOL_STREAM_EVENT_KEY, - type ChatCompletionToolCall, - type ServerToolCallbacks, - type ServerToolExecution, - type ServerToolInvocation, - type ServerToolStreamEvent, - type ServerToolTurn, -} from './types'; - -export const getServerToolStreamEvent = ( - value: unknown, -): ServerToolStreamEvent | null => { - const record = asRecord(value); - const event = asRecord(record?.[SERVER_TOOL_STREAM_EVENT_KEY]); - - if (event?.phase === 'call' && event.invocation) { - return { - invocation: event.invocation as ServerToolInvocation, - phase: 'call', - }; - } - - if (event?.phase === 'result' && event.execution) { - return { - execution: event.execution as ServerToolExecution, - phase: 'result', - }; - } - - return null; -}; - -const serverToolExecutions = new WeakMap(); - -export const attachServerToolExecutions = ( - response: Response, - executions: ServerToolExecution[], -): Response => { - if (executions.length) { - serverToolExecutions.set(response, executions); - } - - return response; -}; - -export const getServerToolExecutions = ( - response: Response, -): ServerToolExecution[] => serverToolExecutions.get(response) ?? []; - -/** - * Per-hop grouping, kept off the wire for the same reason `executions` is: it - * is not part of the OpenAI protocol, so a `/v1/chat/completions` client must - * not see it — a strict validator can reject the extra field, and the tool - * data would otherwise be sent twice. - */ -const serverToolTurns = new WeakMap(); - -export const attachServerToolTurns = ( - response: Response, - turns: ServerToolTurn[], -): Response => { - if (turns.length) { - serverToolTurns.set(response, turns); - } - - return response; -}; - -export const getServerToolTurns = ( - response: Response, -): ServerToolTurn[] | undefined => serverToolTurns.get(response); - -export const buildServerToolInvocation = ( - toolCall: ChatCompletionToolCall, - iteration: number, - index: number, -): ServerToolInvocation => - isWebFetchToolCall(toolCall) - ? { - id: toolCall.id ?? `server_tool_${iteration}_${index}`, - input: extractFetchQuery(toolCall.function?.arguments), - type: 'web_fetch', - } - : { - id: toolCall.id ?? `server_tool_${iteration}_${index}`, - input: { query: extractSearchQuery(toolCall.function?.arguments) }, - type: 'web_search', - }; - -export const executeServerToolInvocations = async ({ - callbacks, - fetchProvider, - invocations, - searchProvider, -}: { - callbacks?: ServerToolCallbacks; - fetchProvider: WebFetchProvider | null; - invocations: ServerToolInvocation[]; - searchProvider: WebSearchProvider | null; -}): Promise< - Array<{ - content: string; - execution: ServerToolExecution; - tool_call_id: string; - }> -> => { - invocations.forEach((invocation) => callbacks?.onCall?.(invocation)); - - return await Promise.all( - invocations.map(async (invocation) => { - if (invocation.type === 'web_fetch') { - const result = await runWebFetchResult({ - provider: fetchProvider, - query: invocation.input, - }); - const execution: ServerToolExecution = { ...invocation, result }; - callbacks?.onResult?.(execution); - - return { - content: result.content, - execution, - tool_call_id: invocation.id, - }; - } - - const result = await runWebSearchResult({ - provider: searchProvider, - query: invocation.input.query, - }); - const execution: ServerToolExecution = { ...invocation, result }; - callbacks?.onResult?.(execution); - - return { - content: result.content, - execution, - tool_call_id: invocation.id, - }; - }), - ); -}; diff --git a/lib/server/proxy/server-tool/stream.ts b/lib/server/proxy/server-tool/stream.ts deleted file mode 100644 index 8233816..0000000 --- a/lib/server/proxy/server-tool/stream.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { isLocalServerToolCall } from './classify'; -import type { ChatCompletionMessage, ChatCompletionToolCall } from './types'; -import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; - -export const mergeStreamingToolName = ( - previous: string, - incoming: string, -): string => { - if (!previous || incoming.startsWith(previous)) return incoming; - if (!incoming || previous.endsWith(incoming)) return previous; - return previous + incoming; -}; - -export const aggregateStreamingToolCalls = ( - deltas: ChatCompletionToolCall[], -): ChatCompletionToolCall[] => { - const calls = new Map< - string, - ChatCompletionToolCall & { - function: { arguments: string; name: string }; - } - >(); - const latestKeyByIndex = new Map(); - - deltas.forEach((delta, position) => { - const indexedKey = - typeof delta.index === 'number' - ? latestKeyByIndex.get(delta.index) - : undefined; - const key = - indexedKey ?? - (delta.id ? `id:${delta.id}` : undefined) ?? - (typeof delta.index === 'number' - ? `index:${delta.index}` - : `position:${position}`); - const current = calls.get(key) ?? { - function: { arguments: '', name: '' }, - index: delta.index, - }; - - current.id = delta.id ?? current.id; - current.index = delta.index ?? current.index; - current.type = delta.type ?? current.type; - current.function.arguments += delta.function?.arguments ?? ''; - current.function.name = mergeStreamingToolName( - current.function.name, - delta.function?.name ?? '', - ); - calls.set(key, current); - - if (typeof delta.index === 'number') { - latestKeyByIndex.set(delta.index, key); - } - }); - - return [...calls.values()]; -}; - -/** - * Result of streaming one upstream response while watching for server-tool - * calls, so a follow-up iteration can decide what happened. - * - * `localCalls` and `remainingCalls` partition the aggregated tool calls the - * way the execution loop needs them; `frames` are the frames that were held - * back because they carried tool-call deltas. - */ -export interface ServerToolProbe { - content: string; - frames: string[]; - localCalls: ChatCompletionToolCall[]; - reasoning: string; - remainingCalls: ChatCompletionToolCall[]; - role: string; - toolCalls: ChatCompletionToolCall[]; - usage: unknown; -} - -export const probeServerToolStream = async ({ - canContinue, - context, - emitRaw, - fetchProvider, - onReader, - ownedNames, - response, - searchProvider, -}: { - canContinue: () => boolean; - context: { - responseCreated: number; - responseId: string; - responseModel: string; - responseObject: string; - role: string; - usage: unknown; - }; - emitRaw: (frame: string) => void; - fetchProvider: WebFetchProvider | null; - ownedNames?: Set; - /** - * Hands the active reader to the caller's cancellation path. Without it a - * disconnect cannot interrupt a read that is already parked: the loop only - * notices the cancellation once upstream produces another chunk, which a - * stalled upstream never does. - */ - onReader?: (reader: ReadableStreamDefaultReader | null) => void; - response: Response; - searchProvider: WebSearchProvider | null; -}): Promise => { - const frames: string[] = []; - const toolCallDeltas: ChatCompletionToolCall[] = []; - const decoder = new TextDecoder(); - const reader = response.body!.getReader(); - onReader?.(reader); - let buffer = ''; - let content = ''; - let reasoning = ''; - - const inspectFrame = (frame: string): void => { - const line = frame - .split(/\r?\n/) - .find((segment) => segment.startsWith('data:')); - - if (!line) { - emitRaw(frame); - return; - } - - const raw = line.slice(5).trim(); - if (!raw) return; - if (raw === '[DONE]') { - frames.push(frame); - return; - } - - try { - const chunk = JSON.parse(raw) as { - choices?: Array<{ - delta?: ChatCompletionMessage & { - tool_calls?: ChatCompletionToolCall[]; - }; - finish_reason?: string | null; - }>; - created?: number; - id?: string; - model?: string; - object?: string; - usage?: unknown; - }; - context.responseId = chunk.id ?? context.responseId; - context.responseModel = chunk.model ?? context.responseModel; - context.responseObject = - chunk.object?.replace(/\.chunk$/, '') ?? context.responseObject; - context.responseCreated = chunk.created ?? context.responseCreated; - context.usage = chunk.usage ?? context.usage; - const choice = chunk.choices?.[0]; - const delta = choice?.delta; - context.role = delta?.role ?? context.role; - content += delta?.content ?? ''; - reasoning += delta?.reasoning_content ?? delta?.reasoning ?? ''; - - // A tool-call frame is held rather than forwarded: if the turn turns out - // to invoke a server tool, the call has to be answered locally instead - // of being handed to the client as an unresolved call. Anything else the - // delta carried — most importantly the text the model wrote before - // deciding to search — still belongs to the visible turn, so it is - // re-emitted without the tool call. - if (delta?.tool_calls?.length) { - toolCallDeltas.push(...delta.tool_calls); - frames.push(frame); - - const visibleDelta = { ...delta }; - delete visibleDelta.tool_calls; - - if (Object.keys(visibleDelta).length) { - const visible = JSON.stringify({ - ...chunk, - choices: [{ ...choice, delta: visibleDelta, finish_reason: null }], - }); - emitRaw(`data: ${visible}`); - } - return; - } - - if (choice?.finish_reason === 'tool_calls') { - frames.push(frame); - return; - } - } catch { - emitRaw(frame); - return; - } - - emitRaw(frame); - }; - - while (true) { - const chunk = await reader.read(); - if (!canContinue()) break; - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - const split = buffer.split(/\r?\n\r?\n/); - buffer = split.pop() ?? ''; - split.forEach(inspectFrame); - } - - if (buffer.trim()) inspectFrame(buffer); - reader.releaseLock(); - onReader?.(null); - - const toolCalls = aggregateStreamingToolCalls(toolCallDeltas); - const isLocalCall = (toolCall: ChatCompletionToolCall): boolean => - isLocalServerToolCall({ - fetchProvider, - ownedNames, - searchProvider, - toolCall, - }); - - return { - content, - frames, - localCalls: toolCalls.filter(isLocalCall), - reasoning, - remainingCalls: toolCalls.filter((toolCall) => !isLocalCall(toolCall)), - role: context.role, - toolCalls, - usage: context.usage, - }; -}; diff --git a/lib/server/proxy/server-tool/turns.ts b/lib/server/proxy/server-tool/turns.ts deleted file mode 100644 index 877b9e2..0000000 --- a/lib/server/proxy/server-tool/turns.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { asRecord, readReasoning } from '../../shared/content'; -import type { - ChatCompletionMessage, - ChatCompletionPayload, - ChatCompletionToolCall, - JsonRecord, - ServerToolExecution, - ServerToolTurn, -} from './types'; - -export const sumUsage = (accumulated: unknown, incoming: unknown): unknown => { - const left = asRecord(accumulated); - const right = asRecord(incoming); - - if (!left) { - return incoming ?? null; - } - - if (!right) { - return accumulated; - } - - const merged: JsonRecord = { ...left }; - - for (const [key, value] of Object.entries(right)) { - const previous = left[key]; - - if (typeof value === 'number' && typeof previous === 'number') { - merged[key] = previous + value; - } else if (value !== undefined) { - merged[key] = value; - } - } - - return merged; -}; - -/** - * Folds completed search results into the assistant text and re-emits the - * outstanding tool calls unchanged, so a turn that mixed search with - * client-side calls stays a valid transcript. The client sees its own calls - * come back as if upstream had returned them directly. - * - * The findings are folded only when the route has no other way to carry them. - * A route that renders them structurally passes - * `findingsAsStructuredBlocks`, and the text is left alone: the results are - * already on the wire as a result block, and a second copy in the prose is - * what the user reads as the model reciting its own search output. - */ -export const buildMixedTurnPayload = ({ - findingsAsStructuredBlocks = false, - message, - payload, - remainingCalls, - searchResults, - usage, -}: { - findingsAsStructuredBlocks?: boolean; - message: ChatCompletionMessage | undefined; - payload: ChatCompletionPayload; - remainingCalls: ChatCompletionToolCall[]; - searchResults: string[]; - usage?: unknown; -}): ChatCompletionPayload => { - const existingText = - typeof message?.content === 'string' && message.content.trim() - ? message.content.trim() - : ''; - const findings = findingsAsStructuredBlocks - ? '' - : searchResults.filter(Boolean).join('\n\n'); - const content = [existingText, findings].filter(Boolean).join('\n\n'); - - return { - ...payload, - ...(usage ? { usage } : {}), - choices: (payload.choices ?? []).map((choice, index) => - index === 0 - ? { - ...choice, - finish_reason: 'tool_calls', - message: { - ...(choice.message ?? {}), - content: content || null, - role: 'assistant', - tool_calls: remainingCalls, - }, - } - : choice, - ), - }; -}; - -/** - * Pairs each hop's reasoning and text with the calls that hop made. - * - * The three arrays are index-aligned — entry N is hop N — so zipping them back - * together is what restores the grouping a joined string cannot express. `texts` - * and `reasonings` are one entry longer than `executions`, because the closing - * hop answers instead of calling another tool. - */ -export const buildIntermediateTurns = ({ - executions, - reasonings, - texts, -}: { - executions: ServerToolExecution[][]; - reasonings: string[]; - texts: string[]; -}): ServerToolTurn[] => - Array.from( - { length: Math.max(reasonings.length, texts.length) }, - (_, index) => ({ - // Only `executions` can run short: the closing hop answers without - // calling anything, so it has an entry in the prose arrays but none here. - // The three arrays stay aligned because every hop appends to all of them. - executions: executions[index] ?? [], - reasoning: reasonings[index], - text: texts[index], - }), - ); - -/** - * Folds the text a multi-hop turn produced before its later server-tool calls - * into the payload the client receives. - * - * Only the last iteration's message is in `payload`, but a turn that searched - * more than once spoke before each search, and that text is part of the turn: - * dropping it hides the model's reasoning from the user and leaves the - * client's transcript out of step with what the model actually said. - * - * The same hops are also re-grouped into the `turns` half of the result, - * because the folded strings cannot express where one hop ends and the next - * begins. That half travels beside the response rather than inside it: the - * payload is what an OpenAI-protocol client receives, and `turns` is not part - * of that protocol, so it is handed over out of band like `executions`. - * - * Shared with the image-generation loop, which has the same shape: a local - * tool call is replayed with its result appended, so only the final hop's - * message would otherwise survive. - */ -export const withIntermediateTurns = ({ - executions, - payload, - reasonings, - texts, -}: { - executions: ServerToolExecution[][]; - payload: ChatCompletionPayload; - reasonings: string[]; - texts: string[]; -}): { payload: ChatCompletionPayload; turns: ServerToolTurn[] } => { - const extraText = texts.filter(Boolean).join('\n\n'); - const extraReasoning = reasonings.filter(Boolean).join('\n\n'); - const [first, ...rest] = payload.choices ?? []; - - if (!first) { - return { payload, turns: [] }; - } - - const message = first.message ?? {}; - const existingText = - typeof message.content === 'string' ? message.content : ''; - - // Nothing from the earlier hops and nothing to fold in — but the hops may - // still have run tools, which is exactly the case a caller consuming `turns` - // needs: a hop that called a tool without speaking first is still a hop. - if (!extraText && !extraReasoning && !executions.length) { - return { payload, turns: [] }; - } - - const content = [extraText, existingText].filter(Boolean).join('\n\n'); - const reasoning = [extraReasoning, readReasoning(message)] - .filter(Boolean) - .join('\n\n'); - - return { - payload: { - ...payload, - choices: [ - { - ...first, - message: { - ...message, - content, - ...(reasoning ? { reasoning_content: reasoning } : {}), - }, - }, - ...rest, - ], - }, - // Per-hop grouping for renderers that can express it. The joined strings - // above stay as the OpenAI-shaped view; a client that builds Anthropic - // content blocks needs to know where one hop's reasoning ends and the next - // begins, which a joined string has already lost. The closing hop is the - // model's final answer, so it carries no further calls. - turns: buildIntermediateTurns({ - executions, - reasonings: [...reasonings, readReasoning(message)], - texts: [...texts, existingText], - }), - }; -}; - -export { readReasoning }; diff --git a/lib/server/proxy/server-tool/types.ts b/lib/server/proxy/server-tool/types.ts deleted file mode 100644 index 89c5c97..0000000 --- a/lib/server/proxy/server-tool/types.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - WebFetchQuery, - WebFetchResponse, - WebSearchResponse, -} from '../../search/types'; -import type { ChatRequestBody } from '../codebuddy'; - -export const MAX_SEARCH_ITERATIONS = 5; -export const STREAM_TEXT_CHUNK_LENGTH = 1024; - -export type JsonRecord = Record; - -export interface ChatCompletionToolCall { - id?: string; - index?: number; - type?: string; - function?: { - arguments?: string; - name?: string; - }; -} - -export interface ChatCompletionMessage { - content?: string | null; - reasoning?: string; - reasoning_content?: string; - role?: string; - tool_calls?: ChatCompletionToolCall[]; -} - -export interface ChatCompletionPayload { - choices?: Array<{ - finish_reason?: string | null; - index?: number; - message?: ChatCompletionMessage; - }>; - created?: number; - /** - * `status` is the upstream HTTP status, carried so a downstream mapper can - * name the real error type instead of guessing it from the message text. It - * is absent for a payload that already reported an error of its own. - */ - error?: { message?: string; status?: number }; - id?: string; - model?: string; - object?: string; - usage?: unknown; -} - -/** - * Result of one server-tool pass. - * - * `response` is null when no tool could be executed: the request still has to - * be sent, but with the server-tool declarations already stripped, so the - * caller falls through to its ordinary upstream path. - */ -export interface ServerToolLoopResult { - body: ChatRequestBody; - executions: ServerToolExecution[]; - response: Response | null; - /** - * One entry per server-tool hop, in the order the model produced them. - * - * Carries the grouping `message.content` / `reasoning_content` cannot: a - * multi-hop turn joins every hop into one string per kind, which loses where - * one hop's reasoning ends and the next begins. Travels beside the response - * rather than inside it, because it is not part of the OpenAI protocol — a - * block renderer reads it off the response through `getServerToolTurns`. - * Empty when no hop ran. - */ - turns: ServerToolTurn[]; -} - -export type ServerToolInvocation = - | { - id: string; - input: { query: string }; - type: 'web_search'; - } - | { - id: string; - input: WebFetchQuery; - type: 'web_fetch'; - }; - -export type ServerToolExecution = - | (Extract & { - result: WebSearchResponse; - }) - | (Extract & { - result: WebFetchResponse; - }); - -/** - * One server-tool hop as the model produced it. - * - * `reasoning` and `text` are what the model wrote before the calls in - * `executions`; both are empty when it called tools without speaking first. The - * last hop of a turn usually has no executions, because the model answered - * instead of reaching for another tool. - */ -export interface ServerToolTurn { - executions: ServerToolExecution[]; - reasoning: string; - text: string; -} - -export interface ServerToolCallbacks { - emitStreamEvents?: boolean; - /** - * Set by routes that render a server tool's findings structurally — - * Anthropic's `web_search_tool_result` block — instead of as prose. Those - * routes must not also fold the same findings into the assistant text, or - * the user sees the results twice: once as a result block and once as if - * the model had written them. - */ - findingsAsStructuredBlocks?: boolean; - onCall?: (invocation: ServerToolInvocation) => void; - onResult?: (execution: ServerToolExecution) => void; -} - -export const SERVER_TOOL_STREAM_EVENT_KEY = 'x-codebuddy2api-server-tool'; - -export type ServerToolStreamEvent = - | { invocation: ServerToolInvocation; phase: 'call' } - | { execution: ServerToolExecution; phase: 'result' }; - -export type ServerToolUpstreamMode = - 'buffer' | 'detect-both' | 'detect-fetch' | 'detect-search' | 'stream'; diff --git a/lib/server/proxy/server-tool/args.ts b/lib/server/proxy/server-tools/args.ts similarity index 100% rename from lib/server/proxy/server-tool/args.ts rename to lib/server/proxy/server-tools/args.ts diff --git a/lib/server/proxy/server-tools/classify.ts b/lib/server/proxy/server-tools/classify.ts new file mode 100644 index 0000000..7274d81 --- /dev/null +++ b/lib/server/proxy/server-tools/classify.ts @@ -0,0 +1,425 @@ +import { + buildWebFetchToolDefinition, + buildWebSearchToolDefinition, + normalizeToolName, + WEB_FETCH_TOOL_TYPE_PREFIX, + WEB_SEARCH_TOOL_TYPE_PREFIX, +} from '../../search/tool'; +import { asRecord } from '../../shared/content'; +import type { ChatCompletionToolCall, ServerToolKind } from './types'; + +/** + * Decides which tool declarations the provider — this proxy — is meant to run, + * and rewrites them into functions upstream can call. + * + * ## Only the declared type counts + * + * A provider-executed tool is declared with a type of its own: Anthropic sends + * `web_search_20250305` and `web_fetch_20250910`, the Responses API sends + * `web_search_preview`. A client's own function is declared as + * `{type: 'function', function: {...}}` on OpenAI, or as a bare + * `{name, input_schema}` with no type at all on Anthropic. So the type is both + * necessary and sufficient to tell them apart. + * + * The name is deliberately never consulted. `normalizeToolName` strips case and + * separators — `WebSearch` and `web_search` both become `websearch` — so a + * name-based test cannot tell Claude Code's own `WebSearch` function from the + * server tool. Matching on the name made the proxy answer a call the client had + * every intention of resolving itself: Claude Code never received the + * `tool_use` block it needed, so the search it asked for never happened and the + * turn ended with an answer invented from memory. + * + * The distinction is what the corrected flow turns on. Claude Code declares + * `WebSearch` as an ordinary function and resolves it itself; only when it has + * a `WebSearch` result to fill in does it issue a sub-request whose tools carry + * the server type, and that sub-request is the one that runs here. + */ + +/** + * How many searches one request may run when the client does not say. + * + * Anthropic's own default. A client that cares sends `max_uses` on the + * declaration; this is only the fallback for one that does not. + */ +export const DEFAULT_MAX_SEARCH_USES = 5; + +/** + * Ceiling on a client-declared `max_uses`. + * + * Every use is a sequential upstream round trip, so an unbounded value is an + * unbounded request: `max_uses: 1000` would hold the connection for a thousand + * calls. Generous enough that no real client is constrained. + */ +export const MAX_SEARCH_USES_CEILING = 20; + +/** + * The search budget the client asked for. + * + * Anthropic declares it as `max_uses` on the server tool, and it bounds the + * whole turn rather than each hop: a model that refines its query twice has + * used two of the eight, not two of eight per round. + */ +/** + * The `max_uses` the client declared for one server tool. + * + * Read per kind, never merged: `max_uses` is declared on the individual tool, + * so a fetch's budget must not cap the searches, nor the other way round. + */ +export const readMaxUses = (tools: unknown, kind: ServerToolKind): number => { + if (!Array.isArray(tools)) { + return DEFAULT_MAX_SEARCH_USES; + } + + const declared = tools + .filter((tool) => classifyServerToolDeclaration(tool) === kind) + .map((tool) => asRecord(tool)?.max_uses) + .filter((value): value is number => typeof value === 'number') + .filter((value) => Number.isFinite(value) && value >= 0); + + if (!declared.length) { + return DEFAULT_MAX_SEARCH_USES; + } + + return Math.min(MAX_SEARCH_USES_CEILING, Math.floor(Math.min(...declared))); +}; + +const SERVER_TOOL_PREFIXES: ReadonlyArray<{ + kind: ServerToolKind; + prefix: string; +}> = [ + { kind: 'web_search', prefix: WEB_SEARCH_TOOL_TYPE_PREFIX }, + { kind: 'web_fetch', prefix: WEB_FETCH_TOOL_TYPE_PREFIX }, +]; + +const OPENAI_FUNCTION_TYPE = 'function'; + +/** + * Classifies one tool declaration, or returns `null` for a client-owned tool. + */ +export const classifyServerToolDeclaration = ( + tool: unknown, +): ServerToolKind | null => { + const record = asRecord(tool); + + if (!record) { + return null; + } + + const type = typeof record.type === 'string' ? record.type.trim() : ''; + + // No type is Anthropic's shorthand for a client function, and `function` is + // OpenAI's. Both mean the client resolves the call, whatever the tool is + // called — including when it is called `web_search`. + if (!type || normalizeToolName(type) === OPENAI_FUNCTION_TYPE) { + return null; + } + + const normalized = normalizeToolName(type); + const match = SERVER_TOOL_PREFIXES.find(({ prefix }) => + normalized.startsWith(normalizeToolName(prefix)), + ); + + return match?.kind ?? null; +}; + +export interface ServerToolDeclarations { + fetch: boolean; + search: boolean; +} + +/** The provider-executed declarations in `tools`, or `null` when there are none. */ +export const findServerToolDeclarations = ( + tools: unknown, +): ServerToolDeclarations | null => { + if (!Array.isArray(tools) || !tools.length) { + return null; + } + + const kinds = new Set( + tools + .map(classifyServerToolDeclaration) + .filter((kind): kind is ServerToolKind => kind !== null), + ); + + if (!kinds.size) { + return null; + } + + return { fetch: kinds.has('web_fetch'), search: kinds.has('web_search') }; +}; + +const declarationName = (tool: unknown): string => { + const record = asRecord(tool); + const fn = asRecord(record?.function); + + return typeof fn?.name === 'string' + ? fn.name + : typeof record?.name === 'string' + ? record.name + : ''; +}; + +/** + * Whether any client-owned function collides with the name this proxy is about + * to register for `kind`. + * + * Both would arrive upstream under the same name, and a model calling it gets + * no way to say which it meant — so the call is left to the client rather than + * guessed at. This is the same normalization collision this file exists to + * avoid, reached from the other side: two declarations this time, one by type + * and one by name, that upstream cannot tell apart. + * + * Answered for one kind at a time, never once for the whole request: a + * collision on the fetch name is not a reason to withhold a search the client + * also declared, and a single request-wide verdict would do exactly that. + */ +export const hasAmbiguousServerToolName = ( + tools: unknown, + kind: ServerToolKind, +): boolean => { + if (!Array.isArray(tools)) { + return false; + } + + const serverNames = new Set(); + const clientNames = new Set(); + + tools.forEach((tool) => { + // Exact, not normalised. Normalising makes `WebSearch` and `web_search` + // the same string, so a client declaring its own `WebSearch` next to the + // server tool looked like a collision and had the whole server-tool + // feature switched off — the declarations are already told apart by + // their declared type, so the names never needed comparing loosely. + const name = declarationName(tool); + + if (!name) { + return; + } + + if (classifyServerToolDeclaration(tool) === kind) { + serverNames.add(name); + } else { + clientNames.add(name); + } + }); + + return [...serverNames].some((name) => clientNames.has(name)); +}; + +export interface RewrittenServerTools { + /** How many calls of each kind this turn may make; see {@link readMaxUses}. */ + maxUses: { web_fetch: number; web_search: number }; + /** + * Which server tool a call names, or `null` when the call is not one the + * proxy injected. Matched exactly — see the note in {@link rewriteServerTools}. + */ + classifyCall: (toolCall: ChatCompletionToolCall) => ServerToolKind | null; + /** + * Which declared server tools the proxy will execute. A declaration the proxy + * cannot run — no backend configured — is still rewritten upstream, but is + * left for the client to resolve. + */ + executable: ServerToolDeclarations; + /** Sorts a tool call into one the proxy runs and one the client resolves. */ + isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean; + tools: unknown[]; +} + +/** + * Replaces provider-executed declarations with the functions upstream calls. + * + * Upstream has no server tools, so every provider-executed declaration — + * runnable or not — has to become a plain function before it goes out; leaving + * the declared type in place would send a shape upstream rejects. + * + * Returns `null` when no provider-executed tool is declared, so a caller can + * skip the turn entirely and forward the request untouched. + */ +export const rewriteServerTools = ({ + declarations, + fetchProvider, + searchProvider, + tools, +}: { + declarations: ServerToolDeclarations; + fetchProvider: unknown; + searchProvider: unknown; + tools: unknown[]; +}): RewrittenServerTools => { + // Ambiguity is resolved in the client's favour; see + // {@link hasAmbiguousServerToolName}. Read per kind, so that a clash on one + // server tool's name does not withhold the other. + const ambiguous = { + web_fetch: hasAmbiguousServerToolName(tools, 'web_fetch'), + web_search: hasAmbiguousServerToolName(tools, 'web_search'), + }; + + const maxUses = { + web_fetch: readMaxUses(tools, 'web_fetch'), + web_search: readMaxUses(tools, 'web_search'), + }; + + const executable: ServerToolDeclarations = { + fetch: declarations.fetch && Boolean(fetchProvider) && !ambiguous.web_fetch, + search: + declarations.search && Boolean(searchProvider) && !ambiguous.web_search, + }; + + /** + * Which server tool each name the proxy injected belongs to. + * + * Keyed by the exact name first. `search/tool.ts` records that upstream + * echoes these back respelled — `WebSearch`, `Web Fetch` — so the normalised + * form is registered too, but only when the client declared no colliding + * name of its own; see the note at the registration site. + * + * A miss is not an error to be recovered from. It means the call is not + * ours, and it goes back to the client — which is the safe direction to be + * wrong in. + */ + const definitions = new Map(); + + /** Whether the proxy runs `kind`, as opposed to leaving it to the client. */ + const runsLocally = (kind: ServerToolKind): boolean => + kind === 'web_search' ? executable.search : executable.fetch; + + const rewritten = tools.flatMap((tool) => { + const kind = classifyServerToolDeclaration(tool); + + if (!kind) { + return [tool]; + } + + // Withdrawn rather than offered when nothing here can run it. Offering it + // anyway would have the model call it and hand the client a `tool_use` for + // a name it declared as a *provider-executed* tool and has no handler for + // — no search, and a turn the client cannot complete. Answering from + // memory is the honest degradation. + if (!runsLocally(kind)) { + return []; + } + + const definition = + kind === 'web_search' + ? buildWebSearchToolDefinition() + : buildWebFetchToolDefinition(); + + definitions.set(definition.name, kind); + + // Stays callable on every hop: the model decides when it has enough, and + // removing it here would forbid exactly the follow-up search that makes a + // server tool worth having. + return [{ type: 'function', function: definition }]; + }); + + /** + * Which server tool `toolCall` names, or `null` when it is not one of ours. + * + * Compared exactly, for the reason above. + */ + const classifyCall = ( + toolCall: ChatCompletionToolCall, + ): ServerToolKind | null => { + const name = toolCall.function?.name; + + if (typeof name !== 'string') { + return null; + } + + // Exact first; the normalised spelling is only a fallback, registered + // solely when nothing collides, for an upstream that respells the name it + // was given. Looking up the raw name alone would make that fallback dead. + return ( + definitions.get(name) ?? definitions.get(normalizeToolName(name)) ?? null + ); + }; + + /** + * Respelled names are only safe to claim when no client function normalises + * onto one of ours. + * + * A *normalised* test even though `hasAmbiguousServerToolName` is an exact + * one, and deliberately so: the fallback matches in normalised space, so + * that is where its safety has to be judged. A client declaring its own + * `WebSearch` must keep it — otherwise the proxy would answer exactly the + * call that client declared the tool to handle itself. + */ + const clientNormalised = new Set( + tools + .filter((tool) => !classifyServerToolDeclaration(tool)) + .map((tool) => normalizeToolName(declarationName(tool))), + ); + + for (const [name, kind] of [...definitions]) { + if (!clientNormalised.has(normalizeToolName(name))) { + definitions.set(normalizeToolName(name), kind); + } + } + + /** Whether the proxy runs this call, as opposed to leaving it to the client. */ + const isExecutableCall = (toolCall: ChatCompletionToolCall): boolean => { + const kind = classifyCall(toolCall); + + return kind ? runsLocally(kind) : false; + }; + + return { + classifyCall, + executable, + isExecutableCall, + maxUses, + tools: rewritten, + }; +}; + +/** + * Drops a `tool_choice` that names a tool no longer on offer. + * + * Withdrawing a server tool nothing here can run leaves a forced choice + * pointing at it otherwise, and an upstream that validates the two together + * rejects the request instead of letting the model answer from memory. + */ +export const reconcileToolChoice = ( + toolChoice: unknown, + tools: unknown, +): unknown => { + const name = getForcedToolName(toolChoice); + + if (!name || !Array.isArray(tools)) { + return toolChoice; + } + + const offered = tools.some( + (tool) => asRecord(asRecord(tool)?.function)?.name === name, + ); + + return offered ? toolChoice : undefined; +}; + +/** Whether the proxy will run any server tool at all. */ +export const hasExecutableServerTool = ( + executable: ServerToolDeclarations, +): boolean => executable.fetch || executable.search; + +/** + * The tool a `tool_choice` forces, in either protocol's shape. + * + * Anthropic sends `{type: 'tool', name}` and OpenAI `{type: 'function', + * function: {name}}`; a translated body carries the OpenAI shape, while a + * caller reading the client's own request sees the Anthropic one. + */ +export const getForcedToolName = (toolChoice: unknown): string | null => { + const record = asRecord(toolChoice); + + if (!record) { + return null; + } + + const fn = asRecord(record.function); + + return typeof fn?.name === 'string' + ? fn.name + : typeof record.name === 'string' + ? record.name + : null; +}; diff --git a/lib/server/proxy/server-tools/execute.ts b/lib/server/proxy/server-tools/execute.ts new file mode 100644 index 0000000..31aee48 --- /dev/null +++ b/lib/server/proxy/server-tools/execute.ts @@ -0,0 +1,103 @@ +import { runWebFetchResult, runWebSearchResult } from '../../search'; +import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; +import { extractFetchQuery, extractSearchQuery } from './args'; +import type { + ChatCompletionToolCall, + ServerToolExecution, + ServerToolInvocation, + ServerToolKind, +} from './types'; + +/** + * Turns one tool call into the invocation a backend runs. + * + * `kind` comes from the classifier that already recognised the call, rather + * than being re-derived from the name here: re-deriving it means re-spelling + * it, and respelling tool names is exactly how the client's own `WebSearch` + * came to be mistaken for the server tool. + */ +export const buildServerToolInvocation = ( + toolCall: ChatCompletionToolCall, + kind: ServerToolKind, + index: number, +): ServerToolInvocation => { + const id = toolCall.id ?? `server_tool_${index}`; + + return kind === 'web_fetch' + ? { + id, + input: extractFetchQuery(toolCall.function?.arguments), + type: 'web_fetch', + } + : { + id, + input: { query: extractSearchQuery(toolCall.function?.arguments) }, + type: 'web_search', + }; +}; + +export interface ServerToolRunResult { + /** Text handed back upstream as the tool's result message. */ + content: string; + execution: ServerToolExecution; + tool_call_id: string; +} + +/** + * Runs every invocation and returns one tool message per call. + * + * Failures become text rather than exceptions: the arguments came from the + * model, so the useful outcome is for it to see what went wrong and retry or + * answer without the findings — not for the whole turn to fail. + */ +export const executeServerToolInvocations = async ({ + fetchProvider, + invocations, + onCall, + onResult, + searchProvider, +}: { + fetchProvider: WebFetchProvider | null; + invocations: ServerToolInvocation[]; + onCall?: (invocation: ServerToolInvocation) => void; + onResult?: (execution: ServerToolExecution) => void; + searchProvider: WebSearchProvider | null; +}): Promise => { + return await Promise.all( + invocations.map(async (invocation) => { + // Announced before the call runs, so a client watching the stream sees + // the search start rather than only its result. + onCall?.(invocation); + + if (invocation.type === 'web_fetch') { + const result = await runWebFetchResult({ + provider: fetchProvider, + query: invocation.input, + }); + const execution: ServerToolExecution = { ...invocation, result }; + + onResult?.(execution); + + return { + content: result.content, + execution, + tool_call_id: invocation.id, + }; + } + + const result = await runWebSearchResult({ + provider: searchProvider, + query: invocation.input.query, + }); + const execution: ServerToolExecution = { ...invocation, result }; + + onResult?.(execution); + + return { + content: result.content, + execution, + tool_call_id: invocation.id, + }; + }), + ); +}; diff --git a/lib/server/proxy/server-tools/index.ts b/lib/server/proxy/server-tools/index.ts new file mode 100644 index 0000000..e3a3082 --- /dev/null +++ b/lib/server/proxy/server-tools/index.ts @@ -0,0 +1,45 @@ +export { + classifyServerToolDeclaration, + findServerToolDeclarations, + getForcedToolName, + hasAmbiguousServerToolName, + hasExecutableServerTool, + readMaxUses, + reconcileToolChoice, + rewriteServerTools, +} from './classify'; +export type { RewrittenServerTools, ServerToolDeclarations } from './classify'; +export { + buildServerToolInvocation, + executeServerToolInvocations, +} from './execute'; +export { + parseBufferedPayload, + readBufferedChatCompletionPayload, +} from './payload'; +export { synthesizeChatCompletionStream } from './sse'; +export { + foldIntermediateTexts, + prepareServerToolTurn, + resolveServerToolBackends, + runServerToolTurn, +} from './turn'; +export type { ServerToolKind } from './types'; +export { + attachServerToolExecutions, + EMPTY_PREAMBLE, + getServerToolExecutions, + getServerToolFollowUpMessages, + STREAM_TEXT_CHUNK_LENGTH, +} from './types'; +export type { + ChatCompletionMessage, + ServerToolSegment, + ChatCompletionPayload, + ChatCompletionToolCall, + JsonRecord, + ServerToolExecution, + ServerToolInvocation, + ServerToolPreamble, + ServerToolTurnOutcome, +} from './types'; diff --git a/lib/server/proxy/server-tool/payload.ts b/lib/server/proxy/server-tools/payload.ts similarity index 58% rename from lib/server/proxy/server-tool/payload.ts rename to lib/server/proxy/server-tools/payload.ts index 1339645..e99b8fd 100644 --- a/lib/server/proxy/server-tool/payload.ts +++ b/lib/server/proxy/server-tools/payload.ts @@ -1,38 +1,6 @@ import { extractErrorMessage } from '../../shared/http'; import type { ChatCompletionPayload } from './types'; -/** - * Rebuilds a failed upstream response so its body can be read again. - * - * A `Response` body can only be consumed once. The loop reads it to decide - * whether the model asked for a server tool, and handing the same object back - * used to leave the route layer — which reads it again to build the answer the - * client actually sees — with a spent body: the second read threw - * "Body already used" and the client got a 500 in place of the real upstream - * status. Draining it here and replaying the bytes in a fresh response keeps - * both reads working and preserves the body verbatim, so an upstream error - * detail that is not valid JSON still reaches the client intact. - * - * `content-length` and `content-encoding` are dropped: the body is re-emitted - * rather than re-encoded, and a stale length would describe bytes the upstream - * compressed before this layer ever saw them. - */ -export const buildServerToolFailureResponse = async ( - response: Response, -): Promise => { - const headers = new Headers(response.headers); - - headers.delete('content-length'); - headers.delete('content-encoding'); - headers.set('content-type', 'application/json'); - - return new Response(await response.text(), { - headers, - status: response.status, - statusText: response.statusText, - }); -}; - /** * Parses a buffered upstream body, tolerating a failure that is not JSON. * @@ -57,13 +25,20 @@ export const parseBufferedPayload = ( } }; +/** + * Reads and parses a buffered upstream body. + * + * `buffered` is passed in rather than read here because the caller has already + * consumed the response to get at it: a `Response` body can be read once, and + * reading it again throws "Body already used". The response is still needed for + * its status and headers. + */ export const readBufferedChatCompletionPayload = async ( response: Response, + buffered?: string, ): Promise => { - // Cloned so the failure path can replay the body verbatim; see - // {@link buildServerToolFailureResponse}. - const buffered = await response.clone().text(); - const payload = parseBufferedPayload(buffered, response.ok); + const text = buffered ?? (await response.clone().text()); + const payload = parseBufferedPayload(text, response.ok); if (!response.ok || payload.error) { const ownMessage = payload.error?.message; @@ -73,7 +48,7 @@ export const readBufferedChatCompletionPayload = async ( // code has no message to find, and the JSON is still the only record of // what happened. An empty body says nothing, so it falls all the way // through to the generic message instead of winning on being non-null. - const detail = buffered.trim(); + const detail = text.trim(); return { ...payload, diff --git a/lib/server/proxy/server-tool/sse.ts b/lib/server/proxy/server-tools/sse.ts similarity index 100% rename from lib/server/proxy/server-tool/sse.ts rename to lib/server/proxy/server-tools/sse.ts diff --git a/lib/server/proxy/server-tools/turn.ts b/lib/server/proxy/server-tools/turn.ts new file mode 100644 index 0000000..c53a1ee --- /dev/null +++ b/lib/server/proxy/server-tools/turn.ts @@ -0,0 +1,812 @@ +import { + getActiveConfig, + getCodeBuddyApiEndpoint, + isWebFetchEnabled, + isWebSearchEnabled, +} from '../../domain/config'; +import { resolveFetchProvider, resolveSearchProvider } from '../../search'; +import type { WebFetchProvider, WebSearchProvider } from '../../search/types'; +import { asRecord, readReasoning } from '../../shared/content'; +import type { ChatRequestBody } from '../codebuddy'; +import { + buildServerToolInvocation, + executeServerToolInvocations, +} from './execute'; +import { + findServerToolDeclarations, + getForcedToolName, + reconcileToolChoice, + type RewrittenServerTools, + rewriteServerTools, +} from './classify'; +import { readBufferedChatCompletionPayload } from './payload'; +import type { + ChatCompletionMessage, + ChatCompletionPayload, + ChatCompletionToolCall, + JsonRecord, + ServerToolExecution, + ServerToolInvocation, + ServerToolKind, + ServerToolPreamble, + ServerToolSegment, + ServerToolTurnOutcome, +} from './types'; +import { attachServerToolExecutions, EMPTY_PREAMBLE, sumUsage } from './types'; + +/** + * One server-tool turn. + * + * A loop, bounded by the `max_uses` the client declared: ask upstream, run + * whatever server tools it reached for, feed the findings back, and ask again + * until the model stops asking or the budget is spent — then one closing call + * with the server tools withdrawn so it answers with what it has. + * + * The loop is over the *server* tools only. Claude Code's own `WebSearch` is an + * ordinary client function; a call to it is never picked up, because Claude + * Code wants to resolve it itself. That is the whole distinction this module + * exists to protect. + */ + +/** + * Resolves the configured backends. + * + * A `passthrough` backend resolves to `null`, which is what tells the turn the + * client runs the tool itself: the declaration is still rewritten into a + * function upstream can call, but the call that comes back is handed to the + * client rather than executed here. + */ +export const resolveServerToolBackends = async (): Promise<{ + fetchProvider: WebFetchProvider | null; + searchProvider: WebSearchProvider | null; +}> => { + const [searchEnabled, fetchEnabled, config] = await Promise.all([ + isWebSearchEnabled(), + isWebFetchEnabled(), + getActiveConfig(), + ]); + const resolveEndpoint = getCodeBuddyApiEndpoint; + + return { + fetchProvider: fetchEnabled + ? resolveFetchProvider( + config.CODEBUDDY_WEB_FETCH_BACKEND, + resolveEndpoint, + ) + : null, + searchProvider: searchEnabled + ? resolveSearchProvider( + config.CODEBUDDY_WEB_SEARCH_BACKEND, + resolveEndpoint, + ) + : null, + }; +}; + +/** + * Decides whether `tools` contain a server tool this deployment will run. + * + * Takes the already-translated chat tools: both translators keep a + * provider-executed declaration's type, so this works for Anthropic + * (`web_search_20250305`) and the Responses API (`web_search_preview`) alike. + * + * Returns `null` when no provider-executed tool is declared, in which case the + * caller forwards the request untouched. Otherwise `rewrite.tools` must be sent + * upstream whether or not anything will be executed: upstream has no server + * tools, and leaving a declared type in the request sends a shape it rejects. + */ +export const prepareServerToolTurn = async ( + tools: unknown, +): Promise<{ + providers: { + fetchProvider: WebFetchProvider | null; + searchProvider: WebSearchProvider | null; + }; + rewrite: RewrittenServerTools; +} | null> => { + const declarations = findServerToolDeclarations(tools); + + if (!declarations) { + return null; + } + + // `declarations` is only non-null when `tools` is a non-empty array, so the + // rewrite cannot decline. + const toolList = tools as unknown[]; + + const { fetchProvider, searchProvider } = await resolveServerToolBackends(); + + return { + providers: { fetchProvider, searchProvider }, + rewrite: rewriteServerTools({ + declarations, + fetchProvider, + searchProvider, + tools: toolList, + }), + }; +}; + +/** + * Prose and reasoning a message carries, as the part of the turn that came + * *before* the tool call it is attached to. + */ +const readPreamble = ( + message: ChatCompletionMessage | undefined, +): ServerToolPreamble => { + if (!message) { + return EMPTY_PREAMBLE; + } + + return { + reasoning: readReasoning(message).trim(), + text: typeof message.content === 'string' ? message.content.trim() : '', + }; +}; + +/** + * Rebuilds a response whose body has already been read. + * + * The turn reads the first upstream response to see whether the model asked for + * a server tool, so the object handed back has to be reconstructed from those + * bytes — a caller reading it a second time would otherwise hit "Body already + * used". + * + * Framing headers are dropped: they describe the original body, which has since + * been re-serialized to a different length. Keeping `content-length` truncates + * the new one and keeping `content-encoding: gzip` makes a client try to + * decompress plaintext. + */ +const rebuildResponse = (response: Response, body: string): Response => { + const headers = new Headers(response.headers); + + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.delete('transfer-encoding'); + // Every caller hands this a `JSON.stringify(...)` result, so an inherited + // label is a lie as soon as upstream answers a `stream: false` hop with SSE + // — and a client that trusts it tries to read an event stream out of JSON. + headers.set('content-type', 'application/json; charset=utf-8'); + + return new Response(body, { + headers, + status: response.status, + statusText: response.statusText, + }); +}; + +/** + * Rewrites a payload so only the calls the client still has to answer survive. + * + * The executed ones have already been answered — their findings travel as + * `executions`, which each renderer turns into real protocol blocks. Leaving + * them in `tool_calls` too would hand the client a second, unresolved copy. + */ +const keepOutstandingCalls = ( + payload: ChatCompletionPayload, + outstanding: ChatCompletionToolCall[], +): ChatCompletionPayload => { + const [first, ...rest] = payload.choices ?? []; + + if (!first) { + return payload; + } + + return { + ...payload, + choices: [ + { + ...first, + // Only `tool_calls` when something really is outstanding: stamping it + // onto an answer makes every renderer report `stop_reason: 'tool_use'` + // with nothing to satisfy, and the client discards the answer. + finish_reason: outstanding.length ? 'tool_calls' : 'stop', + message: { ...(first.message ?? {}), tool_calls: outstanding }, + }, + ...rest, + ], + }; +}; + +/** + * Strips the proxy's own server-tool calls from a payload, whatever else it + * carries. + * + * Used on the paths that return a payload the client will see without having + * inspected it first — the failures above all. A `tool_use` for a `web_search` + * the client never declared is a call it has no handler for, and the internal + * function is not to be seen outside this turn; see the note in `classify.ts`. + */ +const keepClientCalls = ( + payload: ChatCompletionPayload, + isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean, +): ChatCompletionPayload => + keepOutstandingCalls( + payload, + (payload.choices?.[0]?.message?.tool_calls ?? []).filter( + (toolCall) => !isExecutableCall(toolCall), + ), + ); + +/** + * The response for a turn whose client hung up mid-way. + * + * 499 is nginx's "client closed request". No standard status covers a request + * the caller abandoned, and all the callers do with a non-ok response is stop + * rendering — which is what is wanted, since nobody is listening. + */ +const abortedResponse = (): Response => + new Response( + JSON.stringify({ + error: { message: 'Client closed the request', status: 499 }, + }), + { headers: { 'content-type': 'application/json' }, status: 499 }, + ); + +/** + * Reads a hop's payload, converting a malformed body into an error. + * + * A body that will not parse on a successful status is an upstream failure, + * not a reason to throw out of the turn: everything already searched would be + * billed and lost, and the client would get a JSON-parse message instead of + * whatever upstream actually said. + */ +const readBufferedPayloadSafely = async ( + response: Response, + buffered: string, +): Promise => { + try { + return await readBufferedChatCompletionPayload( + rebuildResponse(response, buffered), + buffered, + ); + } catch { + return { error: { message: 'Upstream returned a malformed response' } }; + } +}; + +/** + * Drops a hop's prose and reasoning, which have already been captured as the + * preamble. Leaving them on the payload too renders them twice — once ahead of + * the searches, once after. + */ +const withoutContent = ( + payload: ChatCompletionPayload, +): ChatCompletionPayload => { + const [first, ...rest] = payload.choices ?? []; + + if (!first) { + return payload; + } + + return { + ...payload, + choices: [ + { + ...first, + message: { + ...(first.message ?? {}), + content: null, + reasoning: undefined, + reasoning_content: undefined, + }, + }, + ...rest, + ], + }; +}; + +const asMessages = (body: ChatRequestBody): JsonRecord[] => + (Array.isArray(body.messages) ? body.messages : []) as JsonRecord[]; + +/** + * Runs the server-tool loop; see the module note above. + * + * Every hop is buffered rather than streamed, because whether the model wants + * another search is only knowable once the hop has finished. A streaming client + * gets the finished turn replayed as SSE instead of a live stream — unavoidable + * here, since the first search has to complete before there is anything to say. + */ +export const runServerToolTurn = async ({ + body, + callUpstream, + fetchProvider, + onCall, + onResult, + rewrite, + searchProvider, + signal, +}: { + body: ChatRequestBody; + /** One round trip to upstream. Always buffered. */ + callUpstream: (body: ChatRequestBody, stream: boolean) => Promise; + fetchProvider: WebFetchProvider | null; + onCall?: (invocation: ServerToolInvocation) => void; + onResult?: (execution: ServerToolExecution) => void; + /** Output of {@link rewriteServerTools} for this request. */ + rewrite: NonNullable>; + searchProvider: WebSearchProvider | null; + /** + * Aborted when the client hangs up. The turn stops spending at the next + * checkpoint rather than finishing the budget for a caller that has gone. + */ + signal?: AbortSignal; +}): Promise => { + const { classifyCall, executable, isExecutableCall, maxUses, tools } = + rewrite; + + const executions: ServerToolExecution[] = []; + // One entry per hop that ran something: the prose that preceded it, plus the + // calls it made. Kept as a list because prose written between two searches + // belongs between the two search blocks, and flattening it loses that. + const segments: ServerToolSegment[] = []; + /** + * Only the messages this turn appended to the transcript it was handed. + * + * The turn builds its continuation internally, but a caller that drives + * upstream across several rounds — the image-generation loop — replays the + * request from its own copy of the messages. It has to be given these, or the + * next round's model is asked to continue a turn whose searches it cannot + * see: the findings were fed to the model once and then discarded. + */ + const appended: JsonRecord[] = []; + let transcript = asMessages(body); + + /** + * The turn as it stands, for a client that is no longer listening. + * + * The searches already run are attached exactly as on the failure paths: + * they really happened and really were billed, and a renderer that recovers + * them from the response is the only record of them. + */ + const aborted = (): ServerToolTurnOutcome => ({ + executions, + followUpMessages: appended, + segments, + response: attachServerToolExecutions( + abortedResponse(), + executions, + appended, + ), + usage, + }); + + /** + * Whether to stop spending on this turn. + * + * Checked between hops, and again before executing and before the closing + * call: a hop is a real upstream round trip and a search is a real backend + * call, and a client that hung up would otherwise be charged for the rest of + * the budget it declared — every remaining search, and the closing answer to + * go with them. + * + * Not checked mid-hop: an upstream call already in flight cannot be recalled + * any cheaper than letting it finish. + */ + const hangUp = (): boolean => signal?.aborted === true; + let usage: unknown = null; + // Counted separately: the client declares `max_uses` on each server tool, so + // a fetch must not spend the search budget — but both need a bound, or a + // turn that only fetches would never terminate. + let searches = 0; + let fetches = 0; + let callCounter = 0; + let firstHop = true; + + while (true) { + if (hangUp()) { + return aborted(); + } + + const response = await callUpstream( + { + ...body, + messages: transcript, + tools, + // Only the first hop honours a forced server tool; after that the + // model chooses, or it would never stop searching. + // + // Reconciled as well as relaxed: a declaration nothing here can run is + // withdrawn from `tools`, so a choice still naming it would point at a + // tool the request no longer offers — which some upstreams reject + // outright. A pin naming one of the client's own tools is untouched. + tool_choice: firstHop + ? reconcileToolChoice(body.tool_choice, tools) + : relaxToolChoice(body.tool_choice, classifyCall), + }, + false, + ); + + const buffered = await response.text(); + const payload = await readBufferedPayloadSafely(response, buffered); + usage = sumUsage(usage, payload.usage); + + // A failure ends the turn: the request did not succeed, and the client + // needs the real status and detail rather than a summary. + if (!response.ok || payload.error) { + return { + executions, + followUpMessages: appended, + segments, + // Attached even on failure: the earlier hops really ran and were + // really billed, and the Responses and image paths recover them from + // the response rather than from the return value. + response: attachServerToolExecutions( + rebuildResponse( + response, + JSON.stringify( + keepClientCalls(withUsage(payload, usage), isExecutableCall), + ), + ), + executions, + appended, + ), + usage, + }; + } + + const message = payload.choices?.[0]?.message; + const toolCalls: ChatCompletionToolCall[] = message?.tool_calls ?? []; + // Each call is resolved to its kind once, here, rather than being + // re-derived from its name further down. + const localCalls = toolCalls.flatMap((toolCall) => { + const kind = classifyCall(toolCall); + + return kind && isExecutableCall(toolCall) ? [{ kind, toolCall }] : []; + }); + const remainingCalls = toolCalls.filter( + (toolCall) => !isExecutableCall(toolCall), + ); + + // The model stopped asking. This hop is the answer. + if (!localCalls.length) { + return { + executions, + followUpMessages: appended, + segments, + response: attachServerToolExecutions( + rebuildResponse(response, JSON.stringify(withUsage(payload, usage))), + executions, + appended, + ), + usage, + }; + } + + // Captured on every hop, up to the first one that actually speaks: the + // model may explain itself before each search, and only the closing + // answer lives in the payload the renderer sees. + + // Clamped to the budget before executing: the bound is only testable + // between hops, so a hop emitting k parallel searches would otherwise run + // them all and overshoot by up to k-1 — billed to the client either way. + const affordable = takeWithinBudget(localCalls, { + fetches, + maxUses, + searches, + }); + + // A turn-scoped counter: indexing within a hop made two hops that omitted + // ids both produce `server_tool_0`, so the transcript carried two calls + // sharing one id. + const invocations = affordable.map(({ kind, toolCall }) => + buildServerToolInvocation(toolCall, kind, callCounter++), + ); + + // Before executing rather than after: a search is real work at a real + // backend, and a client that has already hung up gets nothing from it. + if (hangUp()) { + return aborted(); + } + + const results = await executeServerToolInvocations({ + fetchProvider, + invocations, + ...(onCall ? { onCall } : {}), + ...(onResult ? { onResult } : {}), + searchProvider, + }); + + executions.push(...results.map((result) => result.execution)); + segments.push({ + ...readPreamble(message), + executions: results.map((result) => result.execution), + }); + searches += results.filter( + (result) => result.execution.type === 'web_search', + ).length; + fetches += results.filter( + (result) => result.execution.type === 'web_fetch', + ).length; + + // Only the calls that were actually run: an assistant message promising + // more calls than there are results for violates the chat protocol, and + // upstream rejects the next hop. + const runCalls = invocations.map((invocation, index) => ({ + ...(affordable[index].toolCall as JsonRecord), + id: invocation.id, + })); + + if (runCalls.length) { + const hopMessages: JsonRecord[] = [ + { + ...(message as JsonRecord), + content: message?.content ?? null, + role: message?.role ?? 'assistant', + tool_calls: runCalls, + }, + ...results.map((result) => ({ + role: 'tool', + content: result.content, + tool_call_id: result.tool_call_id, + })), + ]; + + appended.push(...hopMessages); + transcript = [...transcript, ...hopMessages]; + } + + /** + * A hop that also asked for something the client owns cannot be continued + * here: replaying the transcript would leave the client's own calls in an + * assistant message with no result behind them, which upstream rejects. + * The findings go back as they are and the outstanding calls stay the + * client's to resolve — and every protocol this proxy serves can carry + * those findings structurally, so nothing is folded into the text. + */ + if (remainingCalls.length) { + return { + executions, + followUpMessages: appended, + segments, + response: attachServerToolExecutions( + rebuildResponse( + response, + JSON.stringify( + withUsage( + keepOutstandingCalls(withoutContent(payload), remainingCalls), + usage, + ), + ), + ), + executions, + appended, + ), + usage, + }; + } + + firstHop = false; + + // Budget spent. One last call with the server tools withdrawn, so the + // model answers with what it has instead of asking for a search it will + // not get. + // Only kind the turn can actually run counts, and a kind is spent only + // when it has run out. OR-ing the two, or counting every declared kind, + // would end the turn while one of them still had allowance left — or + // never end it at all for a kind that was declared but never used. + // + // A hop that could afford nothing ends the turn for the same reason, and + // it is not covered by the test above: when two kinds are runnable and the + // model keeps asking only for the one that is exhausted, that test stays + // false while the hop executes nothing — so the transcript never advances + // and the loop would re-issue an identical request forever, each one a real + // billed round trip. + const spent = + !affordable.length || + ((!executable.search || searches >= maxUses.web_search) && + (!executable.fetch || fetches >= maxUses.web_fetch)); + + if (spent) { + if (hangUp()) { + return aborted(); + } + + const finalResponse = await callUpstream( + { + ...body, + messages: transcript, + ...withoutServerTools(tools, isExecutableCall, body.tool_choice), + }, + false, + ); + + const finalBuffered = await finalResponse.text(); + // Read safely, like every other hop: a closing call that answers with a + // body that will not parse would otherwise throw out of the turn, and + // every search already run would be billed and lost. + const finalPayload = await readBufferedPayloadSafely( + finalResponse, + finalBuffered, + ); + + usage = sumUsage(usage, finalPayload.usage); + + if (!finalResponse.ok || finalPayload.error) { + return { + executions, + followUpMessages: appended, + segments, + // Attached even on failure: the searches really ran and were really + // billed, and the Responses path recovers them from the response. + response: attachServerToolExecutions( + rebuildResponse( + finalResponse, + JSON.stringify( + keepClientCalls( + withUsage(finalPayload, usage), + isExecutableCall, + ), + ), + ), + executions, + appended, + ), + usage, + }; + } + + /** + * The model may still ask, even with the tool withdrawn. Those calls are + * dropped rather than passed on: the budget is spent, so nothing will + * answer them, and the client never declared a `web_search` it could + * resolve itself. Anything it *does* own survives. + */ + return { + executions, + followUpMessages: appended, + segments, + response: attachServerToolExecutions( + rebuildResponse( + finalResponse, + JSON.stringify( + keepClientCalls(withUsage(finalPayload, usage), isExecutableCall), + ), + ), + executions, + appended, + ), + usage, + }; + } + } +}; + +/** + * Trims a hop's calls to what the budget still allows. + * + * `searches`/`fetches` are the running totals and `maxUses` the bound for + * each kind; a hop may ask for more than is left, and the excess is dropped + * rather than executed and billed. + */ +const takeWithinBudget = ( + calls: T[], + budget: { + fetches: number; + maxUses: { web_fetch: number; web_search: number }; + searches: number; + }, +): T[] => { + const left = { + web_fetch: budget.maxUses.web_fetch - budget.fetches, + web_search: budget.maxUses.web_search - budget.searches, + }; + + return calls.filter((call) => (left[call.kind] -= 1) >= 0); +}; + +/** Drops the server tools from a tool list, leaving the client's own. */ +const withoutServerTools = ( + tools: unknown[], + isExecutableCall: (toolCall: ChatCompletionToolCall) => boolean, + toolChoice: unknown, +): { tool_choice: unknown; tools: unknown[] } => { + const remaining = tools.filter((tool) => { + const name = asRecord(asRecord(tool)?.function)?.name; + + return !isExecutableCall({ + function: { name: typeof name === 'string' ? name : '' }, + }); + }); + + // No `tool_choice` once nothing is left to choose: naming a tool that is not + // on offer is a contradiction some upstreams reject outright, and every + // search already run lives in this hop's transcript. + // + // Reconciled rather than reset to `auto`, so a pin the client set on one of + // its own tools survives the withdrawal — only one naming a withdrawn server + // tool goes, and a client tool left unpinned still gets `auto`. + return { + tool_choice: remaining.length + ? (reconcileToolChoice(toolChoice, remaining) ?? 'auto') + : undefined, + tools: remaining, + }; +}; + +/** + * Writes the running total back onto a payload. + * + * Each hop reports only its own usage, and the client is billed for the whole + * turn — every search plus the answer. + */ +const withUsage = ( + payload: ChatCompletionPayload, + usage: unknown, +): ChatCompletionPayload => + usage === null || usage === undefined ? payload : { ...payload, usage }; + +/** + * Stops a forced server tool from compelling another search. + * + * Claude Code's side request arrives with `tool_choice` pinned to + * `web_search`, and that pin has to hold for the first hop — it is what makes + * the model produce a query instead of answering from memory. Left in place it + * would then force a search on every hop forever, so afterwards it becomes + * `auto`: the model may search again, but it does not have to. + * + * `required` becomes `auto` for the same reason by another route. + */ +const relaxToolChoice = ( + toolChoice: unknown, + classifyCall: (toolCall: ChatCompletionToolCall) => ServerToolKind | null, +): unknown => { + if (!toolChoice) { + return toolChoice; + } + + const name = getForcedToolName(toolChoice); + + // Compared exactly, not by normalised prefix: `tool_choice` carries only a + // name, and a client pinning its own `WebSearch` normalises to the same + // string as the server tool — loosening that would quietly stop the model + // from calling the tool the client pinned. + if (name && classifyCall({ function: { name } }) !== null) { + return 'auto'; + } + + if (toolChoice === 'required') { + return 'auto'; + } + + return toolChoice; +}; + +/** + * Folds the prose earlier hops produced into a payload that only carries the + * last one. + * + * The image-generation loop replays a request with each generated image folded + * back in, so only its final hop's message survives in the payload — but the + * text the model wrote before each call is part of the turn, and dropping it + * leaves the client's transcript out of step with what the model actually said. + */ +export const foldIntermediateTexts = ( + payload: ChatCompletionPayload, + texts: string[], +): ChatCompletionPayload => { + const extraText = texts.filter(Boolean).join('\n\n'); + const [first, ...rest] = payload.choices ?? []; + + if (!first || !extraText) { + return payload; + } + + const message = first.message ?? {}; + const existingText = + typeof message.content === 'string' ? message.content : ''; + + return { + ...payload, + choices: [ + { + ...first, + message: { + ...message, + content: [extraText, existingText].filter(Boolean).join('\n\n'), + }, + }, + ...rest, + ], + }; +}; diff --git a/lib/server/proxy/server-tools/types.ts b/lib/server/proxy/server-tools/types.ts new file mode 100644 index 0000000..322c501 --- /dev/null +++ b/lib/server/proxy/server-tools/types.ts @@ -0,0 +1,251 @@ +/** + * Server tools the proxy runs on the client's behalf. + * + * A client asks a provider to run a search by declaring a *provider-executed* + * tool: Anthropic sends a dated type (`web_search_20250305`), the Responses API + * sends `web_search_preview`. Upstream CodeBuddy has neither, so the proxy + * executes the call against a configured backend and hands the findings back + * as if upstream had produced them. + * + * The loop lives *inside* one request, which is what a server tool means to + * the client: the model asks for a search, the proxy runs it, feeds the + * findings back, and upstream decides whether to search again or write the + * answer. Only the finished turn crosses the wire. + * + * What is deliberately absent is a loop over the *client's* tools. Claude Code + * declares `WebSearch` as an ordinary function and resolves it itself, so a + * call to it goes straight back — the proxy never picks it up. + */ + +import { asRecord } from '../../shared/content'; +import type { + WebFetchQuery, + WebFetchResponse, + WebSearchResponse, +} from '../../search/types'; + +export type JsonRecord = Record; + +export interface ChatCompletionToolCall { + id?: string; + index?: number; + type?: string; + function?: { + arguments?: string; + name?: string; + }; +} + +export interface ChatCompletionMessage { + content?: string | null; + reasoning?: string; + reasoning_content?: string; + role?: string; + tool_calls?: ChatCompletionToolCall[]; +} + +export interface ChatCompletionPayload { + choices?: Array<{ + finish_reason?: string | null; + index?: number; + message?: ChatCompletionMessage; + }>; + created?: number; + /** + * `status` is the upstream HTTP status, carried so a downstream mapper can + * name the real error type instead of guessing it from the message text. It + * is absent for a payload that already reported an error of its own. + */ + error?: { message?: string; status?: number }; + id?: string; + model?: string; + object?: string; + usage?: unknown; +} + +/** Text slice size when a buffered completion is replayed as SSE. */ +export const STREAM_TEXT_CHUNK_LENGTH = 1024; + +/** The two tools this proxy can execute, named by what they do. */ +export type ServerToolKind = 'web_fetch' | 'web_search'; + +export type ServerToolInvocation = + | { + id: string; + input: { query: string }; + type: 'web_search'; + } + | { + id: string; + input: WebFetchQuery; + type: 'web_fetch'; + }; + +export type ServerToolExecution = + | (Extract & { + result: WebSearchResponse; + }) + | (Extract & { + result: WebFetchResponse; + }); + +/** + * Prose the model produced before it reached for a server tool. + * + * Anthropic puts that prose *ahead* of the `server_tool_use` block, so it has + * to travel separately from the answer written after the results came back: + * joining the two would show the user a conclusion before the search that + * produced it. + */ +export interface ServerToolPreamble { + reasoning: string; + text: string; +} + +export const EMPTY_PREAMBLE: ServerToolPreamble = { reasoning: '', text: '' }; + +/** + * One hop of a server-tool turn: what the model said, then what it asked for. + * + * A turn is a list of these. Anthropic interleaves prose and server-tool + * blocks rather than gathering them by kind, so the grouping has to survive + * to the renderer — a single "preamble" loses everything written between two + * searches. + */ +export interface ServerToolSegment { + /** Prose and reasoning the model produced before these calls. */ + reasoning: string; + text: string; + /** The calls this hop ran, in call order. */ + executions: ServerToolExecution[]; +} + +/** + * Result of one server-tool turn. + * + * `response` is the upstream response to render as the assistant's answer, and + * it is always present: the turn has already spent the first upstream call, and + * a caller that re-issued the request would bill the turn twice. + */ +export interface ServerToolTurnOutcome { + /** Calls executed locally, in the order the model made them. */ + executions: ServerToolExecution[]; + /** + * The messages the turn appended to the transcript it was handed: the + * assistant messages carrying its calls, and the tool results behind them. + * + * The turn builds its continuation internally, so a caller that drives + * upstream across several rounds — the image-generation loop — has to splice + * these into its own copy of the messages. Without them the next round asks + * the model to continue a turn whose findings are nowhere in its input: the + * search ran, was billed, and was then thrown away. + */ + followUpMessages: JsonRecord[]; + /** + * The hops, each with the prose that preceded it. The closing answer is not + * here — it is in `response`. + */ + segments: ServerToolSegment[]; + response: Response; + /** + * Token usage for the whole turn, summed across every hop. Carried here + * rather than read off `response` because a hop only ever reports its own + * usage, and the client is billed for all of them. + */ + usage: unknown; +} + +/** + * Out-of-band channel for the calls a turn executed. + * + * The image-generation loop drives upstream itself and may surface a server + * tool call on any of its hops, so it needs to collect executions from + * responses it did not produce. The alternative — threading a collector + * through every layer between the two — would couple them for one field. + */ +const serverToolExecutions = new WeakMap(); + +const serverToolFollowUpMessages = new WeakMap(); + +/** + * Hangs both out-of-band channels on the response a turn hands back. + * + * `followUpMessages` ride along for the same reason `executions` do — see the + * note above — and only ever matter to a caller that drives upstream itself. + */ +export const attachServerToolExecutions = ( + response: Response, + executions: ServerToolExecution[], + followUpMessages: JsonRecord[] = [], +): Response => { + if (executions.length) { + serverToolExecutions.set(response, executions); + } + + if (followUpMessages.length) { + serverToolFollowUpMessages.set(response, followUpMessages); + } + + return response; +}; + +export const getServerToolExecutions = ( + response: Response, +): ServerToolExecution[] => serverToolExecutions.get(response) ?? []; + +/** + * The messages a turn appended to the transcript it was handed, read off a + * response it produced. Empty when no server tool ran. + */ +export const getServerToolFollowUpMessages = ( + response: Response, +): JsonRecord[] => serverToolFollowUpMessages.get(response) ?? []; + +/** + * Adds two usage blocks together. + * + * A turn iterates: every hop upstream is a real request, and the client is + * billed for all of them. Adding only the last would under-report the turn by + * every search that preceded it. + * + * Fields present on either side are summed when both are numbers and taken + * from the right otherwise — a later response supersedes an earlier count for + * the same key rather than inventing a total from two partial readings. + */ +const asUsageRecord = (value: unknown): Record | null => + asRecord(value); + +const isPlainObject = (value: unknown): value is Record => + asRecord(value) !== null; + +export const sumUsage = (accumulated: unknown, incoming: unknown): unknown => { + const left = asUsageRecord(accumulated); + const right = asUsageRecord(incoming); + + if (!left) { + return incoming ?? null; + } + + if (!right) { + return accumulated; + } + + const merged: Record = { ...left }; + + for (const [key, value] of Object.entries(right)) { + const previous = left[key]; + + if (typeof value === 'number' && typeof previous === 'number') { + merged[key] = previous + value; + } else if (isPlainObject(value) && isPlainObject(previous)) { + // Nested blocks such as `prompt_tokens_details` are summed field by + // field. Taking the later hop's object instead would report the cache + // tokens of the last hop only, under-counting the turn. + merged[key] = sumUsage(previous, value); + } else if (value !== undefined) { + merged[key] = value; + } + } + + return merged; +}; diff --git a/lib/server/proxy/web-search-loop.ts b/lib/server/proxy/web-search-loop.ts deleted file mode 100644 index 92ee8a6..0000000 --- a/lib/server/proxy/web-search-loop.ts +++ /dev/null @@ -1,924 +0,0 @@ -import { - getActiveConfig, - getCodeBuddyApiEndpoint, - isWebFetchEnabled, - isWebSearchEnabled, -} from '../domain/config'; -import { resolveFetchProvider, resolveSearchProvider } from '../search'; -import { normalizeSearchBackend } from '../search/tool'; -import type { WebFetchProvider, WebSearchProvider } from '../search/types'; -import { encodeDoneFrame } from '../shared/sse'; - -import type { ChatRequestBody } from './codebuddy'; -import { - isLocalServerToolCall, - isWebFetchTool, - isWebSearchTool, - replaceServerTools, -} from './server-tool/classify'; -import { - buildServerToolInvocation, - executeServerToolInvocations, -} from './server-tool/execution'; -import { - buildServerToolFailureResponse, - parseBufferedPayload, - readBufferedChatCompletionPayload, -} from './server-tool/payload'; -import { synthesizeChatCompletionStream } from './server-tool/sse'; -import { - type ServerToolProbe, - probeServerToolStream, -} from './server-tool/stream'; -import { - buildMixedTurnPayload, - readReasoning, - sumUsage, - withIntermediateTurns, -} from './server-tool/turns'; -import { - MAX_SEARCH_ITERATIONS, - SERVER_TOOL_STREAM_EVENT_KEY, - type ChatCompletionMessage, - type ChatCompletionPayload, - type ChatCompletionToolCall, - type JsonRecord, - type ServerToolCallbacks, - type ServerToolExecution, - type ServerToolLoopResult, - type ServerToolStreamEvent, - type ServerToolTurn, - type ServerToolUpstreamMode, -} from './server-tool/types'; - -/** - * Server-side web search for upstreams that do not implement it. - * - * Anthropic (`web_search_20260209`) and the Responses API - * (`web_search_preview`) both hand search to the provider. CodeBuddy has no - * equivalent, so when a client declares one of those tools the proxy swaps it - * for a plain `web_search` function the model can call, runs the query through - * the configured search backend, and appends the results as a tool message. - * The model then answers normally, and the client never learns the search ran - * locally. - * - * A streaming first response is probed until its first meaningful delta. Plain - * text and reasoning keep the real upstream stream, while a server-tool call - * is buffered because its arguments are only complete once that response ends. - */ - -const createInlineServerToolStream = async ({ - body, - callbacks, - callUpstream, - fetchProvider, - ownedNames, - searchProvider, -}: { - body: ChatRequestBody; - callbacks: ServerToolCallbacks; - callUpstream: ( - body: ChatRequestBody, - mode: ServerToolUpstreamMode, - ) => Promise; - fetchProvider: WebFetchProvider | null; - ownedNames?: Set; - searchProvider: WebSearchProvider | null; -}): Promise => { - const firstResponse = await callUpstream(body, 'stream'); - const contentType = firstResponse.headers.get('content-type') ?? ''; - - if (!contentType.toLowerCase().includes('text/event-stream')) { - return { body, executions: [], response: firstResponse, turns: [] }; - } - - const executions: ServerToolExecution[] = []; - const encoder = new TextEncoder(); - let activeReader: ReadableStreamDefaultReader | null = null; - let cancelled = false; - - // A call is locally executable only when its backend is available; anything - // else stays the client's to answer. - const isLocalCall = (toolCall: ChatCompletionToolCall): boolean => - isLocalServerToolCall({ - fetchProvider, - ownedNames, - searchProvider, - toolCall, - }); - - const emitJson = ( - controller: ReadableStreamDefaultController, - payload: Record, - ): void => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); - }; - const emitServerToolEvent = ( - controller: ReadableStreamDefaultController, - event: ServerToolStreamEvent, - ): void => { - emitJson(controller, { [SERVER_TOOL_STREAM_EVENT_KEY]: event }); - }; - const pipeResponse = async ( - controller: ReadableStreamDefaultController, - response: Response, - ): Promise => { - if (!response.body) return; - const reader = response.body.getReader(); - activeReader = reader; - - while (true) { - const chunk = await reader.read(); - if (cancelled || chunk.done) break; - controller.enqueue(chunk.value); - } - - reader.releaseLock(); - activeReader = null; - }; - - const stream = new ReadableStream({ - start: (controller) => { - const run = async (): Promise => { - activeReader = firstResponse.body!.getReader(); - activeReader.releaseLock(); - const context = { - responseCreated: Math.floor(Date.now() / 1000), - responseId: '', - responseModel: String(body.model ?? 'unknown'), - responseObject: 'chat.completion', - role: 'assistant', - usage: null as unknown, - }; - - const first = await probeServerToolStream({ - canContinue: () => !cancelled, - context, - emitRaw: (frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - fetchProvider, - onReader: (reader) => { - activeReader = reader; - }, - ownedNames, - response: firstResponse, - searchProvider, - }); - if (cancelled) return; - activeReader = null; - - let usage: unknown = context.usage; - const content = first.content; - const reasoning = first.reasoning; - const role = context.role; - - const responseId = context.responseId; - const responseModel = context.responseModel; - const responseObject = context.responseObject; - const responseCreated = context.responseCreated; - - if (!first.localCalls.length) { - first.frames.forEach((frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - ); - controller.close(); - return; - } - - const remainingCalls = first.remainingCalls; - const invocations = first.localCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, 0, index), - ); - - invocations.forEach((invocation) => { - callbacks.onCall?.(invocation); - emitServerToolEvent(controller, { invocation, phase: 'call' }); - }); - const results = await executeServerToolInvocations({ - callbacks: { - onResult: (execution) => { - callbacks.onResult?.(execution); - emitServerToolEvent(controller, { execution, phase: 'result' }); - }, - }, - fetchProvider, - invocations, - searchProvider, - }); - if (cancelled) return; - executions.push(...results.map((result) => result.execution)); - - if (remainingCalls.length) { - // Same opt-out as `buildMixedTurnPayload`: the result event above - // already carries these findings, so a text copy would be the second. - const findings = callbacks.findingsAsStructuredBlocks - ? '' - : results.map((result) => result.content).join('\n\n'); - if (findings) { - emitJson(controller, { - choices: [{ delta: { content: findings }, index: 0 }], - created: responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - }); - } - emitJson(controller, { - choices: [ - { - delta: { tool_calls: remainingCalls }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - created: responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - usage, - }); - controller.enqueue(encodeDoneFrame()); - controller.close(); - return; - } - - const messages = body.messages as JsonRecord[]; - const assistantMessage: JsonRecord = { - role, - content: content || null, - tool_calls: first.toolCalls, - ...(reasoning ? { reasoning_content: reasoning } : {}), - }; - messages.push(assistantMessage); - messages.push( - ...results.map((result) => ({ - role: 'tool', - tool_call_id: result.tool_call_id, - content: result.content, - })), - ); - - let loopBody: ChatRequestBody = { - ...body, - messages, - tool_choice: body.tool_choice ? 'auto' : body.tool_choice, - }; - let finalPayload: ChatCompletionPayload | null = null; - - for ( - let iteration = 1; - iteration < MAX_SEARCH_ITERATIONS; - iteration++ - ) { - const response = await callUpstream(loopBody, 'stream'); - const isEventStream = (response.headers.get('content-type') ?? '') - .toLowerCase() - .includes('text/event-stream'); - - // Upstream answers with JSON rather than SSE when it refuses the - // request, and also when the caller is not streaming at all. Both - // shapes are read the same way; only an error ends the turn here. - const buffered = !isEventStream - ? await readBufferedChatCompletionPayload(response) - : null; - - let probe: ServerToolProbe | null = null; - - if (buffered) { - usage = sumUsage(usage, buffered.usage); - - if (!response.ok || buffered.error) { - emitJson(controller, buffered as JsonRecord); - controller.enqueue(encodeDoneFrame()); - controller.close(); - return; - } - - const bufferedMessage = buffered.choices?.[0]?.message; - const bufferedCalls = bufferedMessage?.tool_calls ?? []; - - // A JSON answer that still asks for a server tool is an - // intermediate step, not the end of the turn: it has to be - // executed and fed back, exactly as a streamed one would be. - if (!bufferedCalls.some(isLocalCall)) { - finalPayload = { - ...buffered, - ...(usage ? { usage } : {}), - }; - break; - } - - probe = { - content: - typeof bufferedMessage?.content === 'string' - ? bufferedMessage.content - : '', - frames: [], - localCalls: bufferedCalls.filter(isLocalCall), - reasoning: readReasoning(bufferedMessage), - remainingCalls: bufferedCalls.filter( - (toolCall) => !isLocalCall(toolCall), - ), - role: bufferedMessage?.role ?? 'assistant', - toolCalls: bufferedCalls, - usage, - }; - } else { - // Streamed rather than buffered: this is the iteration that very - // often ends the turn, and buffering it would make the user wait - // for the whole answer before seeing any of it. Text is forwarded - // as it arrives; only tool-call frames are held, since a server - // tool still has to be answered locally. - probe = await probeServerToolStream({ - canContinue: () => !cancelled, - context, - emitRaw: (frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - fetchProvider, - onReader: (reader) => { - activeReader = reader; - }, - ownedNames, - response, - searchProvider, - }); - if (cancelled) return; - activeReader = null; - usage = sumUsage(usage, context.usage); - - // No server tool to answer, so the held frames — withheld only - // because they *might* have been one — are forwarded as-is. - if (!probe.localCalls.length) { - probe.frames.forEach((frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - ); - controller.close(); - return; - } - } - - // The model is going to search again, so anything it just said is - // part of the visible turn rather than a discarded step. A streamed - // iteration already forwarded it through `emitRaw`, so only a - // buffered one — whose payload never reached the client — needs it - // re-emitted here. - const iterationText = buffered ? probe.content.trim() : ''; - const iterationReasoning = buffered ? probe.reasoning.trim() : ''; - - if (iterationText) { - emitJson(controller, { - choices: [{ delta: { content: iterationText }, index: 0 }], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - }); - } - - if (iterationReasoning) { - emitJson(controller, { - choices: [ - { delta: { reasoning_content: iterationReasoning }, index: 0 }, - ], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: `${responseObject}.chunk`, - }); - } - - const message: ChatCompletionMessage = { - content: probe.content || null, - role: probe.role, - tool_calls: probe.toolCalls, - ...(probe.reasoning ? { reasoning_content: probe.reasoning } : {}), - }; - const nextLocalCalls = probe.localCalls; - const nextRemainingCalls = probe.remainingCalls; - - const nextInvocations = nextLocalCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, iteration, index), - ); - nextInvocations.forEach((invocation) => { - callbacks.onCall?.(invocation); - emitServerToolEvent(controller, { invocation, phase: 'call' }); - }); - const nextResults = await executeServerToolInvocations({ - callbacks: { - onResult: (execution) => { - callbacks.onResult?.(execution); - emitServerToolEvent(controller, { - execution, - phase: 'result', - }); - }, - }, - fetchProvider, - invocations: nextInvocations, - searchProvider, - }); - if (cancelled) return; - executions.push(...nextResults.map((result) => result.execution)); - - if (nextRemainingCalls.length) { - finalPayload = buildMixedTurnPayload({ - findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, - message, - payload: { - choices: [{ message }], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: responseObject, - }, - remainingCalls: nextRemainingCalls, - searchResults: nextResults.map((result) => result.content), - usage, - }); - break; - } - - messages.push(message as JsonRecord); - messages.push( - ...nextResults.map((result) => ({ - role: 'tool', - tool_call_id: result.tool_call_id, - content: result.content, - })), - ); - loopBody = { - ...loopBody, - messages, - tool_choice: loopBody.tool_choice ? 'auto' : loopBody.tool_choice, - }; - } - - if (!finalPayload) { - const response = await callUpstream( - { - ...loopBody, - tools: loopBody.tools?.filter( - (tool) => !isWebSearchTool(tool) && !isWebFetchTool(tool), - ), - }, - 'stream', - ); - const isEventStream = (response.headers.get('content-type') ?? '') - .toLowerCase() - .includes('text/event-stream'); - - if (!isEventStream) { - finalPayload = await readBufferedChatCompletionPayload(response); - - if (!response.ok || finalPayload.error) { - emitJson(controller, finalPayload as JsonRecord); - controller.enqueue(encodeDoneFrame()); - controller.close(); - return; - } - - usage = sumUsage(usage, finalPayload.usage); - finalPayload = { ...finalPayload, ...(usage ? { usage } : {}) }; - } else { - const probe = await probeServerToolStream({ - canContinue: () => !cancelled, - context, - emitRaw: (frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - fetchProvider, - onReader: (reader) => { - activeReader = reader; - }, - ownedNames, - response, - searchProvider, - }); - if (cancelled) return; - activeReader = null; - usage = sumUsage(usage, context.usage); - - // With every server tool stripped, a tool call here can only be a - // client-owned one; hand it back so the client resolves it. - if (probe.remainingCalls.length) { - const fallbackMessage: ChatCompletionMessage = { - content: probe.content || null, - role: probe.role, - tool_calls: probe.toolCalls, - ...(probe.reasoning - ? { reasoning_content: probe.reasoning } - : {}), - }; - - finalPayload = buildMixedTurnPayload({ - findingsAsStructuredBlocks: - callbacks?.findingsAsStructuredBlocks, - message: fallbackMessage, - payload: { - choices: [{ message: fallbackMessage }], - created: context.responseCreated, - id: responseId, - model: responseModel, - object: responseObject, - }, - remainingCalls: probe.remainingCalls, - searchResults: [], - usage, - }); - } else { - probe.frames.forEach((frame) => - controller.enqueue(encoder.encode(`${frame}\n\n`)), - ); - controller.close(); - return; - } - } - } - - await pipeResponse( - controller, - synthesizeChatCompletionStream( - finalPayload, - String(loopBody.model ?? 'unknown'), - ), - ); - controller.close(); - }; - - void run().catch((error) => { - if (!cancelled) controller.error(error); - }); - }, - async cancel(reason): Promise { - cancelled = true; - try { - await activeReader?.cancel(reason); - } finally { - activeReader?.releaseLock(); - activeReader = null; - } - }, - }); - - return { - body, - executions, - response: new Response(stream, { - headers: firstResponse.headers, - status: firstResponse.status, - statusText: firstResponse.statusText, - }), - // The streamed path already emits each hop in order, so no grouping has - // to be reconstructed downstream. - turns: [], - }; -}; - -export const executeWebSearchLoop = async ({ - body, - callUpstream, - callbacks, - detectInitialStream = Boolean(body.stream), -}: { - body: ChatRequestBody; - callUpstream: ( - body: ChatRequestBody, - mode: ServerToolUpstreamMode, - ) => Promise; - callbacks?: ServerToolCallbacks; - detectInitialStream?: boolean; -}): Promise => { - const [searchEnabled, fetchEnabled, config] = await Promise.all([ - isWebSearchEnabled(), - isWebFetchEnabled(), - getActiveConfig(), - ]); - - const resolveEndpoint = getCodeBuddyApiEndpoint; - const searchProvider = searchEnabled - ? resolveSearchProvider( - config.CODEBUDDY_WEB_SEARCH_BACKEND, - resolveEndpoint, - ) - : null; - const fetchProvider = fetchEnabled - ? resolveFetchProvider(config.CODEBUDDY_WEB_FETCH_BACKEND, resolveEndpoint) - : null; - - const replacement = replaceServerTools({ - fetchEnabled, - fetchProvider, - searchEnabled, - searchPassthrough: - normalizeSearchBackend(config.CODEBUDDY_WEB_SEARCH_BACKEND) === - 'passthrough', - searchProvider, - tools: body.tools, - }); - - if (!replacement) { - return null; - } - - const { executes, ownedNames, tools } = replacement; - - // Nothing can be executed, so there is nothing to loop for. The rewritten - // `tools` still have to reach the caller: it forwards them upstream, and the - // stripped declarations have to stay stripped on that path too. - if (!executes) { - return { - body: { ...body, tools }, - executions: [], - response: null, - turns: [], - }; - } - - const messages: JsonRecord[] = body.messages as JsonRecord[]; - let loopBody: ChatRequestBody = { ...body, messages, tools }; - let response: Response | null = null; - let payload: ChatCompletionPayload | null = null; - let usage: unknown = null; - const executions: ServerToolExecution[] = []; - // Text and reasoning the model produced before a *later* server-tool call. - // Only the last iteration's message survives in `payload`, so a multi-hop - // turn has to carry its earlier steps forward explicitly. - const intermediateTexts: string[] = []; - const intermediateReasonings: string[] = []; - // The calls each hop made, parallel to the two arrays above. Block renderers - // need the calls grouped with the prose that produced them, not flattened - // into one list at the end. - const intermediateExecutions: ServerToolExecution[][] = []; - const initialMode: ServerToolUpstreamMode = - searchProvider && fetchProvider - ? 'detect-both' - : searchProvider - ? 'detect-search' - : 'detect-fetch'; - - if (detectInitialStream && callbacks?.emitStreamEvents) { - return await createInlineServerToolStream({ - body: loopBody, - callbacks, - callUpstream, - fetchProvider, - ownedNames, - searchProvider, - }); - } - - for (let iteration = 0; iteration < MAX_SEARCH_ITERATIONS; iteration++) { - response = await callUpstream( - loopBody, - iteration === 0 && detectInitialStream ? initialMode : 'buffer', - ); - - if ( - response.headers - .get('content-type') - ?.toLowerCase() - .includes('text/event-stream') - ) { - return { body: loopBody, executions, response, turns: [] }; - } - - // The payload is only needed to detect a tool call or a failure, so read - // the body once and reuse it: the caller reads it again to build the - // client's answer, and a spent body would surface as a 500. - const buffered = await response.clone().text(); - payload = parseBufferedPayload(buffered, response.ok); - - if (!response.ok || payload.error) { - return { - body: loopBody, - executions, - response: await buildServerToolFailureResponse(response), - turns: [], - }; - } - - usage = sumUsage(usage, payload.usage); - - const message = payload.choices?.[0]?.message; - const toolCalls = message?.tool_calls ?? []; - // The same ownership test the streaming paths use. Matching the name alone - // would execute a client's own `web_fetch` whenever a backend is - // configured, instead of handing the call back. - const isLocalCall = (toolCall: ChatCompletionToolCall): boolean => - isLocalServerToolCall({ - fetchProvider, - ownedNames, - searchProvider, - toolCall, - }); - const localCalls = toolCalls.filter(isLocalCall); - const remainingCalls = toolCalls.filter( - (toolCall) => !isLocalCall(toolCall), - ); - - if (!localCalls.length) { - break; - } - - const iterationText = - typeof message?.content === 'string' ? message.content.trim() : ''; - const iterationReasoning = readReasoning(message).trim(); - - const invocations = localCalls.map((toolCall, index) => - buildServerToolInvocation(toolCall, iteration, index), - ); - const results = await executeServerToolInvocations({ - callbacks, - fetchProvider, - invocations, - searchProvider, - }); - executions.push(...results.map((result) => result.execution)); - - // A turn mixing server tools with client-side calls cannot be continued - // locally: the client owns those calls, and re-issuing the transcript with - // only server-tool results would leave them unanswered, which upstream - // rejects as an invalid tool-call transcript. Run the server tools and - // hand the outstanding calls back so the client resolves them on its next - // turn. The findings ride along in the message text only for routes that - // cannot render them structurally; see `buildMixedTurnPayload`. - if (remainingCalls.length) { - const { payload: folded, turns: priorTurns } = withIntermediateTurns({ - executions: intermediateExecutions, - payload, - reasonings: intermediateReasonings, - texts: intermediateTexts, - }); - // The hops already run, plus this one's own: it called server tools - // before handing the client's calls back, so it is a hop like any other - // and has to stay grouped with them. Dropping it would leave the block - // renderer with no grouping at all, flattening every hop's prose ahead - // of the tool blocks. - // - // `withIntermediateTurns` already built this hop as its closing entry — - // the one that carries the current message's prose — but without the - // calls, because it runs before they exist. So the calls are added to - // that entry rather than appended as a new hop, which would repeat the - // prose. Only when there are no earlier hops does it return nothing and - // a fresh entry has to be built here. - const currentTurn: ServerToolTurn = { - // With no earlier hops there is no closing entry to carry the prose, - // so this hop's own text and reasoning are used directly. They are - // what `withIntermediateTurns` folded into `folded` above, and a block - // renderer renders purely from `turns` once it is non-empty, so - // leaving them out would drop everything the model said here. - ...(priorTurns.at(-1) ?? { - reasoning: iterationReasoning, - text: iterationText, - }), - executions: results.map((result) => result.execution), - }; - const mixedTurns: ServerToolTurn[] = [ - ...priorTurns.slice(0, -1), - currentTurn, - ]; - const mixed = buildMixedTurnPayload({ - findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, - // `buildMixedTurnPayload` reads this iteration's text and reasoning - // off `message`, so only the earlier iterations go on top; the - // current one is folded in by the helper itself. - message: folded.choices?.[0]?.message, - payload, - remainingCalls, - searchResults: results.map((result) => result.content), - usage, - }); - - return { - body: loopBody, - executions, - response: Response.json(mixed, { status: response.status }), - turns: mixedTurns, - }; - } - - // This iteration is complete and the loop continues, so its prose and the - // calls it made both become part of the turn the client sees. Keep the three - // arrays index-aligned: entry N is hop N, so a renderer can pair that hop's - // reasoning, text, and tool calls without guessing. A hop that called tools - // without speaking first still gets an entry — its prose sides stay empty. - const hop = intermediateTexts.length; - - intermediateTexts[hop] = iterationText; - intermediateReasonings[hop] = iterationReasoning; - intermediateExecutions[hop] = results.map((result) => result.execution); - - messages.push(message as JsonRecord); - messages.push( - ...results.map((result) => ({ - role: 'tool', - tool_call_id: result.tool_call_id, - content: result.content, - })), - ); - - // A forced tool_choice would make the model call a server tool forever; - // once the loop is running, let it decide when it has enough. - loopBody = { - ...loopBody, - messages, - tool_choice: loopBody.tool_choice ? 'auto' : loopBody.tool_choice, - }; - payload = null; - } - - // The budget ran out with the model still asking to search or fetch. Drop - // every server tool and ask once more so it answers with what it has: looping - // forever would hang the request, and returning `null` would hand the - // unfinished tool call back to the client, which has no way to resolve it. - if (!payload) { - const finalResponse = await callUpstream( - { - ...loopBody, - tools: loopBody.tools!.filter( - (tool) => !isWebSearchTool(tool) && !isWebFetchTool(tool), - ), - }, - 'buffer', - ); - // Cloned before the read so the failure path can replay the body verbatim - // rather than hand back a spent response the caller cannot read again. - const finalBuffered = await finalResponse.clone().text(); - payload = parseBufferedPayload(finalBuffered, finalResponse.ok); - - usage = sumUsage(usage, payload.usage); - - if (!finalResponse.ok || payload.error) { - return { - body: loopBody, - executions, - response: await buildServerToolFailureResponse(finalResponse), - turns: [], - }; - } - - const final = withIntermediateTurns({ - executions: intermediateExecutions, - payload, - reasonings: intermediateReasonings, - texts: intermediateTexts, - }); - - return { - body: loopBody, - executions, - response: Response.json( - { - ...final.payload, - ...(usage ? { usage } : {}), - }, - { status: finalResponse.status }, - ), - turns: final.turns, - }; - } - - const final = withIntermediateTurns({ - executions: intermediateExecutions, - payload, - reasonings: intermediateReasonings, - texts: intermediateTexts, - }); - - return { - body: loopBody, - executions, - response: Response.json( - { - ...final.payload, - ...(usage ? { usage } : {}), - }, - { status: response!.status }, - ), - turns: final.turns, - }; -}; - -export type { - ChatCompletionMessage, - ChatCompletionPayload, - ChatCompletionToolCall, - ServerToolCallbacks, - ServerToolExecution, - ServerToolInvocation, - ServerToolLoopResult, - ServerToolStreamEvent, - ServerToolTurn, - ServerToolUpstreamMode, -} from './server-tool/types'; - -export { - attachServerToolExecutions, - attachServerToolTurns, - getServerToolExecutions, - getServerToolStreamEvent, - getServerToolTurns, -} from './server-tool/execution'; - -export { synthesizeChatCompletionStream } from './server-tool/sse'; - -export { withIntermediateTurns } from './server-tool/turns'; diff --git a/lib/server/search/tool.ts b/lib/server/search/tool.ts index c9f6c8a..37e9554 100644 --- a/lib/server/search/tool.ts +++ b/lib/server/search/tool.ts @@ -103,60 +103,13 @@ export const buildWebFetchToolDefinition = (): { }; }; -/** - * Marks a translated tool as having been declared by the client as a - * provider-executed server tool. - * - * The Responses path necessarily flattens `web_fetch_20250910` into a plain - * function for upstream, which loses the information that the client asked for a - * server tool rather than declaring its own. The proxy loop later needs that - * distinction: an unexecutable server-tool declaration is dropped, while a - * client-owned function is forwarded untouched. - * - * This is a private, non-standard field that travels only between the Responses - * translator and the proxy loop; the loop strips it before anything is sent - * upstream. - */ -const SERVER_TOOL_MARKER = 'x-codebuddy2api-server-tool'; - -export const markServerTool = (tool: T): T & Record => { - if (!tool || typeof tool !== 'object') { - return tool as T & Record; - } - - return { ...tool, [SERVER_TOOL_MARKER]: true }; -}; - -export const isMarkedServerTool = (tool: unknown): boolean => { - if (!tool || typeof tool !== 'object') { - return false; - } - - return (tool as Record)[SERVER_TOOL_MARKER] === true; -}; - -/** Removes the private marker so nothing non-standard reaches upstream. */ -export const stripServerToolMarker = (tool: T): T => { - if (!tool || typeof tool !== 'object') { - return tool; - } - - const { [SERVER_TOOL_MARKER]: _marker, ...rest } = tool as Record< - string, - unknown - >; - - return rest as T; -}; - /** * Where a server tool runs. * * The names say *who* executes the tool, because that is the decision being * made. `codebuddy` and `codebuddy2api` are both server-side and differ only in * who fetches: CodeBuddy's own agent-tool endpoint versus this machine. - * `passthrough` leaves the tool in the request, so the client (Claude Code, - * Codex) runs it itself. + * `passthrough` leaves the tool to the client (Claude Code, Codex). */ export type SearchBackend = 'codebuddy' | 'searxng' | 'passthrough'; export type FetchBackend = 'codebuddy' | 'codebuddy2api' | 'passthrough'; diff --git a/lib/server/shared/sse.ts b/lib/server/shared/sse.ts index 3217303..d135880 100644 --- a/lib/server/shared/sse.ts +++ b/lib/server/shared/sse.ts @@ -55,3 +55,15 @@ export const encodeEventFrame = (type: string, data: unknown): Uint8Array => /** The frame that ends an SSE stream. */ export const encodeDoneFrame = (): Uint8Array => encoder.encode(`${DONE_FRAME_TEXT}\n\n`); + +/** + * Whether a response is already an SSE stream. + * + * Every route branches on this, because upstream answers with JSON rather than + * SSE both when it refuses a request and when the caller was never streaming — + * and the two have to be read the same way. + */ +export const isEventStream = (response: Response): boolean => + (response.headers.get('content-type') ?? '') + .toLowerCase() + .includes('text/event-stream'); diff --git a/package.json b/package.json index 49426ec..5dceae1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test": "vitest run", "commitlint": "commitlint", "lint-staged": "lint-staged", - "test:ci": "vitest run --coverage --reporter=junit --outputFile=test-report.junit.xml", + "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml", "test:coverage": "vitest run --coverage", "test:patch-branches": "bun scripts/check-patch-branches.ts" }, diff --git a/tests/server/image-generation.test.ts b/tests/server/image-generation.test.ts index d88fd13..cedd9b9 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -22,7 +22,7 @@ import type { ProxyContext } from '@/lib/server/proxy/codebuddy'; import { attachServerToolExecutions, getServerToolExecutions, -} from '@/lib/server/proxy/web-search-loop'; +} from '@/lib/server/proxy/server-tools'; import { handleResponsesRequest, resetResponseSessions, @@ -382,6 +382,120 @@ describe('Responses image support', () => { ]); }); + /** + * The server-tool turn builds its continuation against a transcript it + * keeps to itself, and the image loop replays the request from its own copy + * of the messages. Without handing the turn's hops back, the replayed round + * asks the model to continue a turn whose searches are nowhere in its + * input: the search ran, was billed, and was then discarded. + */ + it('carries the server-tool turn’s searches into the replayed request', async () => { + const secret = await addCredentialWith(); + const previousSearxng = process.env.SEARXNG_URL; + + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + + try { + let chatCall = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + const target = String(url); + + if (target.includes('searx.test')) { + return new Response( + JSON.stringify({ + results: [ + { content: 'a cat', title: 'Cats', url: 'https://cats.test' }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) as unknown as Response; + } + + if (target.includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + + chatCall += 1; + // 1: the model searches. 2: with the findings, it asks for an image. + // 3: the loop's replay, which is what this test is about. + return makeChatResponse( + chatCall === 1 + ? { + content: null, + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"cat photos"}', + name: 'web_search', + }, + id: 'call_search', + type: 'function', + }, + ], + } + : chatCall === 2 + ? { + content: null, + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here is your cat.' }, + ); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'find a cat photo and draw it', + model: 'claude-sonnet-4.6', + tools: [{ type: 'web_search_preview' }, { type: 'image_generation' }], + } as never); + + expect(response.status).toBe(200); + + const chatBodies = requestBodies().filter((body) => + Array.isArray(body.messages), + ); + const replayed = chatBodies.at(-1)?.messages as Array< + Record + >; + const shape = replayed.map((message) => { + const calls = message.tool_calls as + Array<{ function?: { name?: string } }> | undefined; + + return calls?.length + ? `assistant[${calls.map((call) => call.function?.name).join(',')}]` + : String(message.role); + }); + + expect(shape).toEqual([ + 'user', + 'assistant[web_search]', + 'tool', + 'assistant[image_generation]', + 'tool', + ]); + } finally { + if (previousSearxng === undefined) { + delete process.env.SEARXNG_URL; + } else { + process.env.SEARXNG_URL = previousSearxng; + } + + resetWebSearchProviders(); + } + }); + it('emits a failed image_generation_call when generation fails', async () => { const secret = await addCredentialWith(); let chatCall = 0; @@ -1478,6 +1592,75 @@ describe('Responses image support', () => { expect(text).toContain('event: response.web_search_call.completed'); }); + it('carries prose written before the search through the image path', async () => { + delete process.env.SEARXNG_URL; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + const secret = await addCredentialWith(); + let chatCall = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + // The search backend has to answer for an execution to be recorded. + if (url.includes('/agenttool/v1/search')) { + return makeImageResponse({ + results: [ + { + content: 'Current result', + title: 'News', + url: 'https://news.test', + }, + ], + }); + } + + chatCall += 1; + + return makeChatResponse( + chatCall === 1 + ? { + // Speaks before asking, so the turn has a preamble to carry. + content: 'Let me search for that first.', + tool_calls: [ + { + function: { + arguments: '{"query":"cats"}', + name: 'web_search', + }, + id: 'search_1', + type: 'function', + }, + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here it is.' }, + ); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'search then draw', + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ type: 'image_generation' }, { type: 'web_search_preview' }], + } as never); + + const text = await response.text(); + + expect(text).toContain('Let me search for that first.'); + expect(text).toContain('event: response.web_search_call.completed'); + }); + it('replays a buffered stream whose turn produced no message', async () => { // No prose anywhere and a surviving client call on the capped hop means // the mapper emits no message item, so the replay has nothing to diff --git a/tests/server/responses-search-output.test.ts b/tests/server/responses-search-output.test.ts new file mode 100644 index 0000000..8f27e39 --- /dev/null +++ b/tests/server/responses-search-output.test.ts @@ -0,0 +1,326 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextRequest } from 'next/server'; + +import { updateSettings } from '@/lib/server/domain/config'; +import { + addCredential, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { handleResponsesRequest } from '@/lib/server/proxy/responses'; +import { resetWebSearchProviders } from '@/lib/server/search'; + +/** + * The citations a Responses client reads off `output_text`. + * + * Real OpenAI emits a `url_citation` annotation per source so the client can + * render links against the prose. This proxy runs the search itself and the + * results never reached the response, so a client had nothing to show. + */ +describe('responses search output', () => { + const tempRootDir = path.join(process.cwd(), '.tmp-responses-citations-root'); + const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); + + const SEARXNG_ENV_NAMES = [ + 'SEARXNG_URL', + 'SEARXNG_API_KEY', + 'SEARXNG_ENGINES', + 'SEARXNG_LANGUAGE', + 'SEARXNG_MAX_RESULTS', + 'SEARXNG_TIMEOUT_MS', + ] as const; + + /** An output item, as far as these tests read it. */ + interface OutputItem { + content?: Array>; + type: string; + } + + /** A `url_citation`, as the Responses API spells it. */ + interface UrlCitation { + end_index: number; + start_index: number; + title: string; + type: string; + url: string; + } + + interface TurnPayload { + output: OutputItem[]; + output_text: string; + } + + const clearSearxngEnv = (): void => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + + resetWebSearchProviders(); + }; + + const cleanupDir = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); + }; + + const makeJsonResponse = ( + payload: Record, + status = 200, + ): Response => + new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + + const makeRequest = (): NextRequest => + new NextRequest('http://localhost/v1/responses', { + method: 'POST', + headers: { authorization: 'Bearer responses-citations-token' }, + }); + + const enableSearch = async (): Promise => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + }; + + const searchHop = (content: string | null) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"release date"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + }); + + const answerHop = (text: string) => ({ + choices: [ + { finish_reason: 'stop', message: { content: text, role: 'assistant' } }, + ], + }); + + /** + * One turn: the model asks for a search, the proxy serves `results`, and the + * model answers with `answer`. + * + * `tools` is what makes it a server-tool turn — the proxy only runs a search + * for a provider-executed declaration, so a request that declares none never + * reaches the backend at all. + */ + interface TurnOptions { + answer: string; + /** Text the model writes before it searches, when it writes any. */ + preamble?: string; + results: Array>; + tools?: Array>; + } + + const runTurn = async ({ + answer, + preamble, + results, + tools = [{ type: 'web_search_preview' }], + }: TurnOptions): Promise => { + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results }) as unknown as Response; + } + + calls += 1; + + return makeJsonResponse( + calls === 1 && tools.length + ? searchHop(preamble ?? null) + : answerHop(answer), + ) as unknown as Response; + }); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'when did it ship?', + model: 'glm-5.1', + tools, + }); + + return (await response.json()) as TurnPayload; + }; + + /** Every assistant message, in the order it was written. */ + const messages = (payload: TurnPayload): OutputItem[] => + payload.output.filter((item) => item.type === 'message'); + + /** The annotations on one message; the last one unless told otherwise. */ + const annotationsOf = ( + payload: TurnPayload, + messageIndex = -1, + ): UrlCitation[] => + (messages(payload).at(messageIndex)?.content?.[0]?.annotations ?? + []) as UrlCitation[]; + + beforeEach(async () => { + clearSearxngEnv(); + resetCredentialRuntimeState(); + cleanupDir(); + fs.mkdirSync(tempDataDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + await addCredential({ + bearer_token: 'responses-citations-token', + responses_passthrough: false, + user_id: 'responses-citations@example.com', + }); + }); + + afterEach(() => { + clearSearxngEnv(); + cleanupDir(); + vi.restoreAllMocks(); + }); + + it('cites the result whose URL the model quoted, over exactly that span', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'It shipped yesterday — see https://docs.test/release for notes.', + results: [ + { + content: 'A snippet', + title: 'Docs', + url: 'https://docs.test/release', + }, + ], + }); + + const annotations = annotationsOf(payload); + + expect(annotations).toEqual([ + { + end_index: 'It shipped yesterday — see https://docs.test/release' + .length, + start_index: 'It shipped yesterday — see '.length, + title: 'Docs', + type: 'url_citation', + url: 'https://docs.test/release', + }, + ]); + // End-exclusive, and nothing but the URL it points at. + expect( + payload.output_text.slice( + annotations[0].start_index, + annotations[0].end_index, + ), + ).toBe('https://docs.test/release'); + }); + + it('invents nothing for a result the model never quoted', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'It shipped yesterday, apparently.', + results: [ + { + content: 'A snippet', + title: 'Docs', + url: 'https://docs.test/release', + }, + ], + }); + + // The search still ran, so the call item is there — but nothing in the + // answer points at a source, so no span can be claimed. + expect(payload.output.map((item) => item.type)).toContain( + 'web_search_call', + ); + expect(annotationsOf(payload)).toEqual([]); + }); + + it('orders two cited URLs by start_index', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'See https://b.test/second and https://a.test/first.', + results: [ + { content: 'one', title: 'First', url: 'https://a.test/first' }, + { content: 'two', title: 'Second', url: 'https://b.test/second' }, + ], + }); + + const annotations = annotationsOf(payload); + + // The second result is quoted first, so it is annotated first. + expect(annotations).toEqual([ + { + end_index: 'See https://b.test/second'.length, + start_index: 'See '.length, + title: 'Second', + type: 'url_citation', + url: 'https://b.test/second', + }, + { + end_index: 'See https://b.test/second and https://a.test/first'.length, + start_index: 'See https://b.test/second and '.length, + title: 'First', + type: 'url_citation', + url: 'https://a.test/first', + }, + ]); + }); + + it('leaves the preamble unannotated', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'Here is what I found: https://docs.test/release.', + preamble: 'Let me check https://docs.test/release.', + results: [ + { + content: 'A snippet', + title: 'Docs', + url: 'https://docs.test/release', + }, + ], + }); + + const all = messages(payload); + + // Two: what was written before the search, and the answer after it. + expect(all).toHaveLength(2); + expect(all[0].content?.[0]?.text).toBe( + 'Let me check https://docs.test/release.', + ); + // Prose written before the search cannot cite it, even when it quotes a + // URL that later turns out to be a result. + expect(annotationsOf(payload, 0)).toEqual([]); + expect(annotationsOf(payload)).toHaveLength(1); + }); + + it('annotates nothing when no search ran', async () => { + await enableSearch(); + + const payload = await runTurn({ + answer: 'It shipped in March, as I recall.', + results: [], + tools: [], + }); + + expect(payload.output.map((item) => item.type)).toEqual(['message']); + expect(annotationsOf(payload)).toEqual([]); + }); +}); diff --git a/tests/server/responses-stream-citations.test.ts b/tests/server/responses-stream-citations.test.ts new file mode 100644 index 0000000..af61bab --- /dev/null +++ b/tests/server/responses-stream-citations.test.ts @@ -0,0 +1,398 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextRequest } from 'next/server'; + +import { updateSettings } from '@/lib/server/domain/config'; +import { + addCredential, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { handleResponsesRequest } from '@/lib/server/proxy/responses'; +import { resetWebSearchProviders } from '@/lib/server/search'; + +/** + * Citations on a turn the client asked to have streamed. + * + * `runServerToolTurn` asks upstream for a buffered answer on every hop, so a + * server-tool turn is replayed through the buffered mapper rather than mapped + * one SSE chunk at a time — and the buffered mapper is the one that annotates. + * These tests pin that a streamed turn cites exactly what a buffered one does. + */ +describe('responses streaming citations', () => { + const tempRootDir = path.join( + process.cwd(), + '.tmp-responses-stream-citations-root', + ); + const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); + + const SEARXNG_ENV_NAMES = [ + 'SEARXNG_URL', + 'SEARXNG_API_KEY', + 'SEARXNG_ENGINES', + 'SEARXNG_LANGUAGE', + 'SEARXNG_MAX_RESULTS', + 'SEARXNG_TIMEOUT_MS', + ] as const; + + /** An output item, as far as these tests read it. */ + interface OutputItem { + content?: Array<{ annotations?: UrlCitation[]; text?: string }>; + type: string; + } + + /** A `url_citation`, as the Responses API spells it. */ + interface UrlCitation { + end_index: number; + start_index: number; + title: string; + type: string; + url: string; + } + + /** The terminal event of a streamed turn, as far as these tests read it. */ + interface CompletedResponse { + output: OutputItem[]; + output_text: string; + } + + const clearSearxngEnv = (): void => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + + resetWebSearchProviders(); + }; + + const cleanupDir = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); + }; + + const makeResponse = ( + body: string, + contentType: string, + status = 200, + ): Response => + new Response(body, { headers: { 'Content-Type': contentType }, status }); + + const makeJsonResponse = ( + payload: Record, + status = 200, + ): Response => + makeResponse(JSON.stringify(payload), 'application/json', status); + + const makeRequest = (): NextRequest => + new NextRequest('http://localhost/v1/responses', { + method: 'POST', + headers: { authorization: 'Bearer responses-stream-citations-token' }, + }); + + const enableSearch = async (): Promise => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + }; + + /** One SSE frame, as the chat upstream spells it. */ + const frame = (chunk: Record): string => + `data: ${JSON.stringify(chunk)}`; + + const searchCall = { + function: { arguments: '{"query":"release date"}', name: 'web_search' }, + id: 'call_1', + type: 'function', + }; + + /** A hop that asks for a search, as a buffered chat payload. */ + const searchHop = (content: string | null) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { content, role: 'assistant', tool_calls: [searchCall] }, + }, + ], + }); + + /** A hop that answers, as a buffered chat payload. */ + const answerHop = (text: string) => ({ + choices: [ + { finish_reason: 'stop', message: { content: text, role: 'assistant' } }, + ], + }); + + /** + * One hop, as the SSE frames an upstream that ignored `stream: false` would + * send: the same message, spelled as deltas ending in a `finish_reason`. + */ + const asSseBody = ( + content: string | null, + toolCalls: Array>, + finishReason: string, + ): string => + [ + frame({ + choices: [ + { delta: { content, role: 'assistant' }, finish_reason: null }, + ], + }), + ...(toolCalls.length + ? [ + frame({ + choices: [ + { + delta: { tool_calls: toolCalls }, + finish_reason: finishReason, + }, + ], + }), + ] + : [frame({ choices: [{ delta: {}, finish_reason: finishReason }] })]), + 'data: [DONE]', + '', + ].join('\n\n'); + + interface TurnOptions { + answer: string; + /** + * How upstream labels the body of a hop it was asked not to stream. + * + * `application/json` is what an upstream that honours `stream: false` + * returns. `text/event-stream` is the one way a buffered turn could reach + * the chunk-by-chunk mapper, which reads only the label to pick itself — + * and which finds no `data:` frames in a JSON body, so it would answer + * with an empty `output_text` rather than merely an unannotated one. + */ + hopContentType?: 'application/json' | 'text/event-stream'; + /** Text the model writes before it searches, when it writes any. */ + preamble?: string; + results: Array>; + stream?: boolean; + } + + /** + * One turn: the model asks for a search, the proxy serves `results`, and the + * model answers with `answer`. + * + * Returns the response body — SSE when the client asked to stream, JSON + * otherwise. + */ + const runTurn = async ({ + answer, + hopContentType = 'application/json', + preamble, + results, + stream = false, + }: TurnOptions): Promise => { + let calls = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results }) as unknown as Response; + } + + calls += 1; + const isSearchHop = calls === 1; + + if (hopContentType === 'text/event-stream') { + return makeResponse( + isSearchHop + ? asSseBody( + preamble ?? null, + [{ ...searchCall, index: 0 }], + 'tool_calls', + ) + : asSseBody(answer, [], 'stop'), + 'text/event-stream', + ) as unknown as Response; + } + + return makeJsonResponse( + isSearchHop ? searchHop(preamble ?? null) : answerHop(answer), + ) as unknown as Response; + }); + + const response = await handleResponsesRequest(makeRequest(), { + input: 'when did it ship?', + model: 'glm-5.1', + ...(stream ? { stream: true } : {}), + tools: [{ type: 'web_search_preview' }], + }); + + return response.text(); + }; + + /** The events of an SSE body, in the order they were written. */ + const eventsOf = (body: string): Array> => + body + .split('\n\n') + .map((block) => + block.split('\n').find((segment) => segment.startsWith('data: ')), + ) + .filter((line): line is string => typeof line === 'string') + .map((line) => line.slice(6).trim()) + .filter((raw) => raw && raw !== '[DONE]') + .map((raw) => JSON.parse(raw) as Record); + + /** The `response.completed` payload of a streamed turn. */ + const completedOf = (body: string): CompletedResponse => { + const event = eventsOf(body).find( + (item) => item.type === 'response.completed', + ); + + if (!event) { + throw new Error(`no response.completed in:\n${body}`); + } + + return event.response as CompletedResponse; + }; + + /** Every assistant message of a completed turn, in the order written. */ + const messagesOf = (response: CompletedResponse): OutputItem[] => + response.output.filter((item) => item.type === 'message'); + + /** The annotations on one message; the last one unless told otherwise. */ + const annotationsOf = ( + response: CompletedResponse, + messageIndex = -1, + ): UrlCitation[] => + messagesOf(response).at(messageIndex)?.content?.[0]?.annotations ?? []; + + const ANSWER = + 'It shipped yesterday — see https://docs.test/release for notes.'; + const RESULTS = [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test/release' }, + ]; + const CITATION: UrlCitation = { + end_index: 'It shipped yesterday — see https://docs.test/release'.length, + start_index: 'It shipped yesterday — see '.length, + title: 'Docs', + type: 'url_citation', + url: 'https://docs.test/release', + }; + + beforeEach(async () => { + clearSearxngEnv(); + resetCredentialRuntimeState(); + cleanupDir(); + fs.mkdirSync(tempDataDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + await addCredential({ + bearer_token: 'responses-stream-citations-token', + responses_passthrough: false, + user_id: 'responses-stream-citations@example.com', + }); + }); + + afterEach(() => { + clearSearxngEnv(); + cleanupDir(); + vi.restoreAllMocks(); + }); + + it('cites the quoted result on a streamed turn exactly as on a buffered one', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ answer: ANSWER, results: RESULTS, stream: true }), + ); + const buffered = JSON.parse( + await runTurn({ answer: ANSWER, results: RESULTS }), + ) as CompletedResponse; + + expect(annotationsOf(streamed)).toEqual([CITATION]); + expect(annotationsOf(streamed)).toEqual(annotationsOf(buffered)); + // The turn really was a server-tool turn, so a client can trace the + // citation back to a search it was shown. + expect(streamed.output.map((item) => item.type)).toContain( + 'web_search_call', + ); + }); + + it('replays the answer as text deltas, and the citation with the item it closes', async () => { + await enableSearch(); + + const events = eventsOf( + await runTurn({ answer: ANSWER, results: RESULTS, stream: true }), + ); + const deltas = events + .filter((event) => event.type === 'response.output_text.delta') + .map((event) => String(event.delta ?? '')); + + expect(deltas.join('')).toBe(ANSWER); + + const closedMessage = events + .filter((event) => event.type === 'response.output_item.done') + .map((event) => event.item as OutputItem) + .find((item) => item.type === 'message'); + + // The client renders against the item it keeps, so that is where the + // annotation has to be — not only on the terminal event. + expect(closedMessage?.content?.[0]?.annotations).toEqual([CITATION]); + }); + + it('leaves the preamble unannotated on a streamed turn', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ + answer: 'Here is what I found: https://docs.test/release.', + preamble: 'Let me check https://docs.test/release.', + results: RESULTS, + stream: true, + }), + ); + const messages = messagesOf(streamed); + + expect(messages).toHaveLength(2); + // Prose written before the search cannot cite it, even when it quotes a + // URL that later turns out to be a result. + expect(annotationsOf(streamed, 0)).toEqual([]); + expect(annotationsOf(streamed)).toHaveLength(1); + }); + + it('annotates nothing when the model quoted no result', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ + answer: 'It shipped yesterday, apparently.', + results: RESULTS, + stream: true, + }), + ); + + expect(streamed.output.map((item) => item.type)).toContain( + 'web_search_call', + ); + expect(annotationsOf(streamed)).toEqual([]); + }); + + /** + * The invariant that keeps the chunk-by-chunk mapper off this path. + * + * `isEventStream` reads only the content type, and a hop's body is + * re-serialized as JSON before it is handed on — so an upstream that + * answers a buffered hop with an SSE label is the one way a streamed + * server-tool turn could reach that mapper, which finds no `data:` frames + * in a JSON body and would drop the answer whole. + */ + it('still cites when the upstream labels a buffered hop as an event stream', async () => { + await enableSearch(); + + const streamed = completedOf( + await runTurn({ + answer: ANSWER, + hopContentType: 'text/event-stream', + results: RESULTS, + stream: true, + }), + ); + + expect(streamed.output_text).toBe(ANSWER); + expect(annotationsOf(streamed)).toEqual([CITATION]); + }); +}); diff --git a/tests/server/search-providers.test.ts b/tests/server/search-providers.test.ts new file mode 100644 index 0000000..77b8fe3 --- /dev/null +++ b/tests/server/search-providers.test.ts @@ -0,0 +1,2313 @@ +/** + * Coverage for the search provider layer. + * + * The proxy-facing half of the server tools lives in `server-tools/`; this file + * covers the half that actually goes and gets things — the backend registry, + * the two CodeBuddy agent-tool backends, and the local fetch — plus the small + * helpers they share. + * + * Nothing here touches the network. The CodeBuddy backends are driven through a + * stubbed `fetch`, and the local fetch is driven by stubbing `node:http` / + * `node:https` `.request`, which is the only seam it has: it deliberately + * avoids `fetch` so it can pin the socket to an already-validated address. + */ + +import dns from 'node:dns/promises'; +import { EventEmitter } from 'node:events'; +import http from 'node:http'; +import https from 'node:https'; + +import { + getWebSearchProvider, + resetWebSearchProviders, + resolveFetchProvider, + resolveSearchProvider, + runWebFetch, + runWebFetchResult, + runWebSearch, + runWebSearchResult, +} from '@/lib/server/search'; +import { + createCodeBuddyFetchProvider, + normalizeFetchUrl, +} from '@/lib/server/search/providers/codebuddy-fetch'; +import { createCodeBuddySearchProvider } from '@/lib/server/search/providers/codebuddy-search'; +import { + createLocalFetchProvider, + type HostResolver, +} from '@/lib/server/search/providers/local-fetch'; +import { + clampInteger, + collapse, + formatFetchResult, + formatSearchResults, + MAX_SNIPPET_LENGTH, + MAX_TITLE_LENGTH, + readEnv, +} from '@/lib/server/search/shared'; +import { + pickCredentialToken, + resolveCodeBuddyToken, + withCodeBuddyToken, + type TokenResolver, +} from '@/lib/server/search/token'; +import { + buildWebFetchToolDefinition, + DEFAULT_FETCH_BACKEND, + DEFAULT_SEARCH_BACKEND, + FETCH_BACKENDS, + normalizeFetchBackend, + normalizeSearchBackend, + normalizeToolName, + SEARCH_BACKENDS, + WEB_FETCH_TOOL_NAME, +} from '@/lib/server/search/tool'; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +const SEARXNG_ENV_NAMES = [ + 'SEARXNG_URL', + 'SEARXNG_API_KEY', + 'SEARXNG_ENGINES', + 'SEARXNG_LANGUAGE', + 'SEARXNG_MAX_RESULTS', + 'SEARXNG_TIMEOUT_MS', +] as const; + +const clearSearxngEnv = (): void => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + resetWebSearchProviders(); +}; + +const TOKEN = 'agent-tool-token'; +const tokenResolver: TokenResolver = async () => TOKEN; +const endpointResolver = async () => 'https://agent.test/'; + +/** Runs `fn` with a CodeBuddy token in scope, as a proxy request would. */ +const withToken = (fn: () => Promise): Promise => + withCodeBuddyToken(tokenResolver, fn); + +const makeJsonResponse = (payload: unknown, status = 200): Response => + new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const makeTextResponse = (text: string, status = 200): Response => + new Response(text, { status }); + +interface FetchCall { + init: RequestInit; + url: string; +} + +/** Stubs `fetch`, returning a recorder for the calls the backend made. */ +const stubFetch = ( + implementation: (url: string, init: RequestInit) => Promise, +): { calls: FetchCall[] } => { + const calls: FetchCall[] = []; + const mock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const requestInit = (init ?? {}) as RequestInit; + calls.push({ init: requestInit, url }); + + return implementation(url, requestInit); + }, + ); + vi.stubGlobal('fetch', mock); + + return { calls }; +}; + +const stubJsonFetch = ( + payload: unknown, + status = 200, +): { calls: FetchCall[] } => + stubFetch(async () => makeJsonResponse(payload, status)); + +const readJsonBody = (call: FetchCall): Record => + JSON.parse(String(call.init.body)) as Record; + +const readHeaders = (call: FetchCall): Headers => + new Headers(call.init.headers as HeadersInit); + +// -- Fake `node:http` / `node:https` transport ------------------------------ + +type LookupCallback = ( + error: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, +) => void; + +type PinnedLookup = ( + hostname: string, + options: { all?: boolean }, + callback: LookupCallback, +) => void; + +interface PinnedRequestOptions { + headers?: Record; + host?: string; + lookup?: PinnedLookup; + method?: string; + path?: string; + port?: number; + servername?: string; +} + +interface FakeRequest { + destroy: () => void; + emitError: (error: unknown) => void; + end: () => void; + on: (event: string, listener: (error: unknown) => void) => FakeRequest; +} + +interface FakeResponse extends EventEmitter { + destroy: (error?: Error) => void; + headers: Record; + idleTimeoutListener: (() => void) | null; + resume: () => void; + setTimeout: (ms: number, listener: () => void) => FakeResponse; + statusCode: number | undefined; +} + +const createFakeRequest = (): FakeRequest => { + const listeners = new Map void>>(); + const request: FakeRequest = { + destroy: () => undefined, + // Deferred, because `requestPinned` registers its listener *after* + // `request` is handed back — a socket error never arrives that early. + emitError: (error) => { + setTimeout(() => { + for (const listener of listeners.get('error') ?? []) { + listener(error); + } + }, 0); + }, + end: () => undefined, + on: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + + return request; + }, + }; + + return request; +}; + +const createFakeResponse = ( + options: { + headers?: Record; + statusCode?: number | undefined; + } = {}, +): FakeResponse => { + const response = new EventEmitter() as unknown as FakeResponse; + response.headers = options.headers ?? {}; + // A test may pass `statusCode: undefined` on purpose: that is what a + // response carrying no status line looks like to the reader. + response.statusCode = 'statusCode' in options ? options.statusCode : 200; + response.idleTimeoutListener = null; + // Matches `IncomingMessage`: destroying with an error surfaces it on the + // stream, which is how a stalled body reaches the reader. + response.destroy = (error?: Error) => { + if (error) response.emit('error', error); + }; + response.resume = () => undefined; + response.setTimeout = (_ms: number, listener: () => void) => { + response.idleTimeoutListener = listener; + + return response; + }; + + return response; +}; + +interface RespondOptions { + body?: string; + /** `false` leaves the body open, as a server that stalls mid-stream does. */ + endStream?: boolean; + headers?: Record; + statusCode?: number | undefined; +} + +interface TransportHandlerContext { + /** Zero-based index of this request within one provider call. */ + call: number; + options: PinnedRequestOptions; + request: FakeRequest; + respond: (options?: RespondOptions) => FakeResponse; +} + +interface TransportCall { + options: PinnedRequestOptions; + request: FakeRequest; +} + +/** + * Replaces `http.request` / `https.request`. + * + * The handler decides what each request sees; `respond` schedules the response + * for the next tick, because a synchronous callback would run before + * `requestPinned` installs its own timeout. + */ +const installTransport = ( + handler: (context: TransportHandlerContext) => void, +): TransportCall[] => { + const calls: TransportCall[] = []; + + const implementation = ( + options: PinnedRequestOptions, + callback: (response: FakeResponse) => void, + ): FakeRequest => { + const request = createFakeRequest(); + const call = calls.length; + calls.push({ options, request }); + + handler({ + call, + options, + request, + respond: (options: RespondOptions = {}) => { + const response = createFakeResponse({ + headers: options.headers ?? {}, + // `undefined` is meaningful: it is what a response with no status + // line looks like to the reader. + statusCode: 'statusCode' in options ? options.statusCode : 200, + }); + + setTimeout(() => { + callback(response); + + if (options.body) { + response.emit('data', Buffer.from(options.body)); + } + + if (options.endStream ?? true) { + response.emit('end'); + } + }, 0); + + return response; + }, + }); + + return request; + }; + + vi.spyOn(http, 'request').mockImplementation( + implementation as unknown as typeof http.request, + ); + vi.spyOn(https, 'request').mockImplementation( + implementation as unknown as typeof https.request, + ); + + return calls; +}; + +/** A resolver answering with a public address, as ordinary DNS would. */ +const publicResolver: HostResolver = async () => ['93.184.216.34']; + +/** Lets the transport's scheduled response arrive. */ +const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 5)); + +/** Drains pending timers until `done`, so a test cannot leak into the next. */ +const settle = async (done: () => boolean): Promise => { + for (let attempt = 0; attempt < 200 && !done(); attempt += 1) { + await tick(); + } +}; + +/** + * Stops the local fallback before it opens a socket. + * + * The CodeBuddy backend always starts a local fetch alongside the endpoint and + * abandons it when the endpoint wins, so a test of the endpoint alone still has + * to account for that attempt. Answering with a private address makes it fail + * at the address check — no DNS, no transport stub, nothing left in flight. + */ +const blockedResolver: HostResolver = async () => ['127.0.0.1']; + +const trustedResolver: HostResolver = Object.assign(async () => ['10.0.0.5'], { + trusted: true, +}); + +// --------------------------------------------------------------------------- +// Backend selection and tool definitions +// --------------------------------------------------------------------------- + +describe('search tool definitions and backend names', () => { + describe('normalizeSearchBackend', () => { + it('keeps the three known backends', () => { + expect(normalizeSearchBackend('codebuddy')).toBe('codebuddy'); + expect(normalizeSearchBackend('searxng')).toBe('searxng'); + expect(normalizeSearchBackend('passthrough')).toBe('passthrough'); + }); + + it('ignores case and surrounding whitespace', () => { + expect(normalizeSearchBackend(' CodeBuddy ')).toBe('codebuddy'); + expect(normalizeSearchBackend('SEARXNG')).toBe('searxng'); + }); + + it('renames the legacy `none` backend to passthrough', () => { + // `none` used to mean "the client runs it", which reads like "off". + expect(normalizeSearchBackend('none')).toBe('passthrough'); + }); + + it('falls back to the default for a backend only fetch knows', () => { + // `local` renames to `codebuddy2api`, which is not a search backend. + expect(normalizeSearchBackend('local')).toBe(DEFAULT_SEARCH_BACKEND); + expect(normalizeSearchBackend('codebuddy2api')).toBe( + DEFAULT_SEARCH_BACKEND, + ); + }); + + it('falls back to the default for unknown, empty, and missing values', () => { + expect(normalizeSearchBackend('bogus')).toBe('searxng'); + expect(normalizeSearchBackend('')).toBe('searxng'); + expect(normalizeSearchBackend(null)).toBe('searxng'); + expect(normalizeSearchBackend(undefined)).toBe('searxng'); + expect(normalizeSearchBackend(42)).toBe('searxng'); + }); + }); + + describe('normalizeFetchBackend', () => { + it('keeps the three known backends', () => { + expect(normalizeFetchBackend('codebuddy')).toBe('codebuddy'); + expect(normalizeFetchBackend('codebuddy2api')).toBe('codebuddy2api'); + expect(normalizeFetchBackend('passthrough')).toBe('passthrough'); + }); + + it('renames the legacy backends', () => { + expect(normalizeFetchBackend('local')).toBe('codebuddy2api'); + expect(normalizeFetchBackend('none')).toBe('passthrough'); + }); + + it('ignores case and surrounding whitespace', () => { + expect(normalizeFetchBackend(' Local ')).toBe('codebuddy2api'); + expect(normalizeFetchBackend('CODEBUDDY')).toBe('codebuddy'); + }); + + it('falls back to passthrough for unknown values', () => { + expect(normalizeFetchBackend('searxng')).toBe('passthrough'); + expect(normalizeFetchBackend(null)).toBe('passthrough'); + expect(normalizeFetchBackend(undefined)).toBe('passthrough'); + }); + }); + + it('exposes the backend lists the console offers', () => { + expect(SEARCH_BACKENDS).toEqual(['codebuddy', 'searxng', 'passthrough']); + expect(FETCH_BACKENDS).toEqual([ + 'codebuddy', + 'codebuddy2api', + 'passthrough', + ]); + expect(DEFAULT_SEARCH_BACKEND).toBe('searxng'); + expect(DEFAULT_FETCH_BACKEND).toBe('passthrough'); + for (const backend of SEARCH_BACKENDS) { + expect(SEARCH_BACKENDS).toContain(backend); + } + }); + + it('declares web_fetch with a required url and an optional prompt', () => { + const tool = buildWebFetchToolDefinition(); + + expect(tool.name).toBe(WEB_FETCH_TOOL_NAME); + expect(tool.name).toBe('web_fetch'); + expect(tool.parameters).toMatchObject({ + properties: { + prompt: { type: 'string' }, + url: { type: 'string' }, + }, + required: ['url'], + type: 'object', + }); + // The description has to draw the line against search, or a model reaches + // for fetch to look things up. + expect(tool.description).toContain('search for that instead'); + }); + + it('compares tool names without case, separators, or spaces', () => { + expect(normalizeToolName('web_fetch')).toBe('webfetch'); + expect(normalizeToolName('WebFetch')).toBe('webfetch'); + expect(normalizeToolName(' Web Fetch ')).toBe('webfetch'); + expect(normalizeToolName('web-fetch')).toBe('webfetch'); + expect(normalizeToolName('WEB_SEARCH')).toBe('websearch'); + expect(normalizeToolName('web_search_20260209')).toBe( + normalizeToolName('websearch20260209'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Shared rendering helpers +// --------------------------------------------------------------------------- + +describe('search result rendering helpers', () => { + describe('readEnv', () => { + afterEach(() => { + delete process.env.TEST_SEARCH_ENV; + }); + + it('trims the value of a set variable', () => { + process.env.TEST_SEARCH_ENV = ' https://searx.test/ '; + + expect(readEnv('TEST_SEARCH_ENV')).toBe('https://searx.test/'); + }); + + it('returns an empty string when the variable is absent', () => { + expect(readEnv('TEST_SEARCH_ENV')).toBe(''); + }); + }); + + describe('clampInteger', () => { + it('uses the fallback for missing or unparseable input', () => { + expect(clampInteger(undefined, 5, 1, 10)).toBe(5); + expect(clampInteger('', 5, 1, 10)).toBe(5); + expect(clampInteger(' ', 5, 1, 10)).toBe(5); + expect(clampInteger('not-a-number', 5, 1, 10)).toBe(5); + }); + + it('parses a value inside the range', () => { + expect(clampInteger('7', 5, 1, 10)).toBe(7); + expect(clampInteger(' 3 ', 5, 1, 10)).toBe(3); + }); + + it('clamps a value outside the range', () => { + expect(clampInteger('99', 5, 1, 10)).toBe(10); + expect(clampInteger('-4', 5, 1, 10)).toBe(1); + }); + }); + + describe('collapse', () => { + it('collapses runs of whitespace', () => { + expect(collapse(' one\n\n two \t three ', 100)).toBe('one two three'); + }); + + it('truncates to the limit and marks the cut', () => { + expect(collapse('abcdefghij', 5)).toBe('abcd…'); + expect(collapse('abcdefghij', 5)).toHaveLength(5); + }); + + it('leaves text at or under the limit untouched', () => { + expect(collapse('abcde', 5)).toBe('abcde'); + }); + }); + + describe('formatSearchResults', () => { + it('tells the model to answer from memory when nothing came back', () => { + const text = formatSearchResults('nothing here', []); + + expect(text).toContain('returned no results'); + expect(text).toContain('nothing here'); + }); + + it('renders one result in the singular', () => { + const text = formatSearchResults('q', [ + { title: 'Only', url: 'https://a' }, + ]); + + expect(text).toContain('(1 result)'); + expect(text).toContain('1. Only'); + expect(text).toContain('URL: https://a'); + }); + + it('renders several results with their snippets', () => { + const text = formatSearchResults('q', [ + { content: 'First body', title: 'First', url: 'https://a' }, + { content: 'Second body', title: 'Second', url: 'https://b' }, + ]); + + expect(text).toContain('(2 results)'); + expect(text).toContain('1. First'); + expect(text).toContain('2. Second'); + expect(text).toContain('First body'); + expect(text).toContain('Second body'); + expect(text).toContain('Cite the URL'); + }); + + it('substitutes placeholders for missing fields', () => { + const text = formatSearchResults('q', [{}, { title: ' ' }]); + + expect(text).toContain('1. (untitled)'); + expect(text).toContain('2. (untitled)'); + // Neither entry has a URL, so no citation line is emitted. + expect(text).not.toContain('URL:'); + }); + }); + + describe('formatFetchResult', () => { + it('names the source url', () => { + const text = formatFetchResult({ + content: 'Body', + url: 'https://a.test', + }); + + expect(text).toContain('Web fetch result for https://a.test:'); + expect(text).toContain('Body'); + }); + + it('includes the requested focus when the model gave one', () => { + const text = formatFetchResult({ + content: 'Body', + prompt: 'the pricing tiers', + url: 'https://a.test', + }); + + expect(text).toContain('Requested focus: the pricing tiers'); + }); + + it('omits the focus line when there is no prompt', () => { + const text = formatFetchResult({ + content: 'Body', + url: 'https://a.test', + }); + + expect(text).not.toContain('Requested focus'); + }); + + it('clamps an over-long prompt to the title limit', () => { + const text = formatFetchResult({ + content: 'Body', + prompt: 'x'.repeat(MAX_TITLE_LENGTH + 50), + url: 'https://a.test', + }); + + expect(text).toContain('…'); + expect(text).not.toContain('x'.repeat(MAX_TITLE_LENGTH + 1)); + }); + }); + + it('bounds titles and snippets', () => { + expect(MAX_TITLE_LENGTH).toBe(200); + expect(MAX_SNIPPET_LENGTH).toBe(800); + }); +}); + +// --------------------------------------------------------------------------- +// Token plumbing +// --------------------------------------------------------------------------- + +const STORED_TOKEN = 'stored-bearer-token'; + +/** + * The credential the store hands back, mutable so one test can empty it. + * + * Hoisted because the module factory below runs before anything else in this + * file is initialised. + */ +const store = vi.hoisted(() => ({ + credential: { data: { bearer_token: 'stored-bearer-token' } } as { + data: Record; + } | null, +})); + +/** + * Stands in for credential storage. + * + * `resolveCodeBuddyToken` falls back to an unscoped credential lookup when no + * token is in scope, so the fallback is the only path that reads storage — and + * it is reachable outside a proxy turn. Stubbing it here keeps that branch + * deterministic without a credential file on disk. + */ +vi.mock('@/lib/server/domain/credentials', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveCredentialForRequest: async () => store.credential, +})); + +describe('CodeBuddy token plumbing', () => { + afterEach(() => { + store.credential = { data: { bearer_token: STORED_TOKEN } }; + }); + describe('pickCredentialToken', () => { + it('prefers the bearer token', () => { + expect( + pickCredentialToken({ + access_token: 'access', + bearer_token: 'bearer', + }), + ).toBe('bearer'); + }); + + it('falls through a blank bearer token to the access token', () => { + expect( + pickCredentialToken({ access_token: 'access', bearer_token: '' }), + ).toBe('access'); + expect( + pickCredentialToken({ access_token: 'access', bearer_token: ' ' }), + ).toBe('access'); + }); + + it('returns null when both are blank or absent', () => { + expect( + pickCredentialToken({ access_token: ' ', bearer_token: '' }), + ).toBeNull(); + expect(pickCredentialToken({})).toBeNull(); + expect(pickCredentialToken({ bearer_token: null })).toBeNull(); + }); + + it('stringifies and trims non-string values', () => { + expect(pickCredentialToken({ bearer_token: 1234 })).toBe('1234'); + expect(pickCredentialToken({ bearer_token: ' padded ' })).toBe( + 'padded', + ); + }); + }); + + describe('withCodeBuddyToken', () => { + it('makes the token visible to the resolver inside the scope', async () => { + const seen = await withCodeBuddyToken( + async () => 'scoped', + async () => resolveCodeBuddyToken(), + ); + + expect(seen).toBe('scoped'); + }); + + it('keeps the token visible across an await', async () => { + const seen = await withCodeBuddyToken( + async () => 'scoped', + async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + + return resolveCodeBuddyToken(); + }, + ); + + expect(seen).toBe('scoped'); + }); + + it('returns the value the scope produced', async () => { + await expect( + withCodeBuddyToken( + async () => 'scoped', + async () => 'done', + ), + ).resolves.toBe('done'); + }); + + it('falls back to stored credentials outside the scope', async () => { + // No token is in scope, so the resolver reads the credential store — + // and it must not see the token of a scope that is not running. + await expect(resolveCodeBuddyToken()).resolves.toBe(STORED_TOKEN); + }); + + it('returns null when no credential is stored either', async () => { + store.credential = null; + + await expect(resolveCodeBuddyToken()).resolves.toBeNull(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +describe('search provider registry', () => { + beforeEach(() => { + clearSearxngEnv(); + }); + + afterEach(() => { + clearSearxngEnv(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('resolveSearchProvider', () => { + it('builds the CodeBuddy backend', () => { + expect(resolveSearchProvider('codebuddy', endpointResolver)?.id).toBe( + 'codebuddy', + ); + }); + + it('builds a fresh CodeBuddy backend per call', () => { + // A cached backend would freeze the endpoint it was built with. + const first = resolveSearchProvider('codebuddy', endpointResolver); + const second = resolveSearchProvider('codebuddy', endpointResolver); + + expect(first).not.toBe(second); + }); + + it('returns null for passthrough and for its legacy name', () => { + expect(resolveSearchProvider('passthrough')).toBeNull(); + expect(resolveSearchProvider('none')).toBeNull(); + }); + + it('builds the SearXNG backend when one is configured', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + + expect(resolveSearchProvider('searxng')?.id).toBe('searxng'); + }); + + it('returns null for SearXNG when no instance is configured', () => { + expect(resolveSearchProvider('searxng')).toBeNull(); + }); + + it('defaults to SearXNG for an unknown or missing backend', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + + expect(resolveSearchProvider(null)?.id).toBe('searxng'); + expect(resolveSearchProvider(undefined)?.id).toBe('searxng'); + expect(resolveSearchProvider('bogus')?.id).toBe('searxng'); + }); + + it('caches the SearXNG backend until it is reset', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + const first = resolveSearchProvider('searxng'); + + expect(resolveSearchProvider('searxng')).toBe(first); + + resetWebSearchProviders(); + expect(resolveSearchProvider('searxng')).not.toBe(first); + }); + }); + + describe('resolveFetchProvider', () => { + it('builds the local backend and caches it', () => { + const first = resolveFetchProvider('codebuddy2api'); + + expect(first?.id).toBe('local'); + expect(resolveFetchProvider('local')).toBe(first); + }); + + it('rebuilds the local backend after a reset', () => { + const first = resolveFetchProvider('local'); + resetWebSearchProviders(); + + expect(resolveFetchProvider('local')).not.toBe(first); + }); + + it('builds the CodeBuddy backend', () => { + expect(resolveFetchProvider('codebuddy', endpointResolver)?.id).toBe( + 'codebuddy', + ); + }); + + it('returns null for passthrough and for its legacy name', () => { + expect(resolveFetchProvider('passthrough')).toBeNull(); + expect(resolveFetchProvider('none')).toBeNull(); + }); + + it('returns null for an unknown or missing backend', () => { + // Passthrough is the default: a fetch the client did not ask us to run + // is the client's to run. + expect(resolveFetchProvider(null)).toBeNull(); + expect(resolveFetchProvider(undefined)).toBeNull(); + expect(resolveFetchProvider('bogus')).toBeNull(); + }); + }); + + describe('getWebSearchProvider', () => { + it('returns null when SearXNG is not configured', () => { + expect(getWebSearchProvider()).toBeNull(); + }); + + it('returns the cached SearXNG backend when it is', () => { + process.env.SEARXNG_URL = 'https://searx.test/'; + resetWebSearchProviders(); + + expect(getWebSearchProvider()?.id).toBe('searxng'); + expect(getWebSearchProvider()).toBe(getWebSearchProvider()); + }); + }); + + describe('runWebSearchResult', () => { + it('runs the supplied provider', async () => { + const result = await runWebSearchResult({ + provider: { + id: 'stub', + search: async (query) => ({ content: `ok:${query}`, results: [] }), + }, + query: 'hello', + }); + + expect(result.content).toBe('ok:hello'); + expect(result.results).toEqual([]); + }); + + it('reports an unconfigured backend instead of failing', async () => { + await expect( + runWebSearchResult({ backend: 'passthrough', query: 'hello' }), + ).resolves.toMatchObject({ + content: expect.stringContaining('no local search backend'), + results: [], + }); + }); + + it('treats an explicit null provider as unavailable', async () => { + await expect( + runWebSearchResult({ provider: null, query: 'hello' }), + ).resolves.toMatchObject({ results: [] }); + }); + + it('resolves the backend when no provider is supplied', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await withToken(() => + runWebSearchResult({ + backend: 'codebuddy', + query: 'hello', + resolveEndpoint: endpointResolver, + }), + ); + + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/search'); + }); + + it('turns a thrown error into text the model can act on', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'boom', + search: async () => { + throw new Error('connection refused'); + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining( + 'Web search failed: connection refused', + ), + results: [], + }); + }); + + it('reports a timeout as a timeout', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'slow', + search: async () => { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining('timed out'), + }); + }); + + it('reports a non-Error rejection as an unknown failure', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'weird', + search: async () => { + throw 'a string'; + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining('unknown error'), + }); + }); + + it('reports an Error with no message as an unknown failure', async () => { + await expect( + runWebSearchResult({ + provider: { + id: 'silent', + search: async () => { + throw new Error(''); + }, + }, + query: 'hello', + }), + ).resolves.toMatchObject({ + content: expect.stringContaining('unknown error'), + }); + }); + }); + + it('runWebSearch returns only the text', async () => { + await expect( + runWebSearch({ + provider: { + id: 'stub', + search: async () => ({ content: 'the text', results: [] }), + }, + query: 'hello', + }), + ).resolves.toBe('the text'); + }); + + describe('runWebFetchResult', () => { + it('runs the supplied provider', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async (query) => ({ content: `ok:${query.url}` }), + id: 'stub', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ content: 'ok:https://a.test' }); + }); + + it('reports an unconfigured backend instead of failing', async () => { + await expect( + runWebFetchResult({ query: { url: 'https://a.test' } }), + ).resolves.toEqual({ + content: expect.stringContaining('no web fetch backend is enabled'), + }); + }); + + it('treats an explicit null provider as unavailable', async () => { + await expect( + runWebFetchResult({ provider: null, query: { url: 'https://a.test' } }), + ).resolves.toEqual({ + content: expect.stringContaining('Web fetch is unavailable'), + }); + }); + + it('resolves the backend when no provider is supplied', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + const result = await withToken(() => + runWebFetchResult({ + backend: 'codebuddy', + // A loopback url keeps the backend's local fallback — which runs + // alongside the endpoint and is abandoned — at the address check, + // so it never reaches DNS. + query: { url: 'http://127.0.0.1/internal' }, + resolveEndpoint: endpointResolver, + }), + ); + + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/webfetch'); + expect(result.content).toContain('page text'); + }); + + it('turns a thrown error into text', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async () => { + throw new Error('socket hang up'); + }, + id: 'boom', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ + content: expect.stringContaining('Web fetch failed: socket hang up'), + }); + }); + + it('reports a timeout as a timeout', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async () => { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + id: 'slow', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ content: expect.stringContaining('timed out') }); + }); + + it('reports a non-Error rejection as an unknown failure', async () => { + await expect( + runWebFetchResult({ + provider: { + fetch: async () => { + throw 404; + }, + id: 'weird', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toEqual({ content: expect.stringContaining('unknown error') }); + }); + }); + + it('runWebFetch returns only the text', async () => { + await expect( + runWebFetch({ + provider: { + fetch: async () => ({ content: 'the text', url: 'https://a.test' }), + id: 'stub', + }, + query: { url: 'https://a.test' }, + }), + ).resolves.toBe('the text'); + }); +}); + +// --------------------------------------------------------------------------- +// CodeBuddy search backend +// --------------------------------------------------------------------------- + +describe('CodeBuddy search provider', () => { + const provider = ( + options: { maxResults?: number; timeoutMs?: number } = {}, + ) => + createCodeBuddySearchProvider({ + maxResults: options.maxResults, + resolveEndpoint: endpointResolver, + resolveToken: tokenResolver, + timeoutMs: options.timeoutMs, + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('identifies itself', () => { + expect(provider().id).toBe('codebuddy'); + }); + + it('posts to the agent-tool search endpoint', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('latest news'); + + // The endpoint keeps a trailing slash in settings; the path is appended. + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/search'); + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.cache).toBe('no-store'); + expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); + }); + + it('sends the credential and the CLI headers', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('q'); + const headers = readHeaders(calls[0]); + + expect(headers.get('authorization')).toBe(`Bearer ${TOKEN}`); + expect(headers.get('content-type')).toBe('application/json;charset=UTF-8'); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('x-requested-with')).toBe('XMLHttpRequest'); + }); + + it('sends the query, result count, and request type', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('latest news'); + + expect(readJsonBody(calls[0])).toEqual({ + max_results: 5, + query: 'latest news', + type: 'text2text', + }); + }); + + it('parses results into the shared shape', async () => { + stubJsonFetch({ + results: [ + { + snippet: ' Snippet one ', + title: ' First ', + url: ' https://a.test ', + }, + { content: 'From content', title: 'Second', url: 'https://b.test' }, + { title: 'No body', url: 'https://c.test' }, + ], + }); + + const result = await provider().search('q'); + + expect(result.results).toEqual([ + { content: 'Snippet one', title: 'First', url: 'https://a.test' }, + { content: 'From content', title: 'Second', url: 'https://b.test' }, + { content: undefined, title: 'No body', url: 'https://c.test' }, + ]); + expect(result.content).toContain('First'); + expect(result.content).toContain('Snippet one'); + }); + + it('leaves a title and url it was not given undefined', async () => { + // A hit with only a body still has to render, so the entry cannot be + // dropped for want of a title. + stubJsonFetch({ results: [{ snippet: 'body only' }] }); + + const result = await provider().search('q'); + + expect(result.results).toEqual([ + { content: 'body only', title: undefined, url: undefined }, + ]); + expect(result.content).toContain('(untitled)'); + }); + + it('drops entries that are not objects and caps the count', async () => { + stubJsonFetch({ + results: [ + null, + 'not an object', + 7, + ...Array.from({ length: 12 }, (_, index) => ({ + title: `Result ${index}`, + url: `https://${index}.test`, + })), + ], + }); + + const result = await provider({ maxResults: 10 }).search('q'); + + expect(result.results).toHaveLength(10); + expect(result.results[0].title).toBe('Result 0'); + }); + + it('clamps the requested result count', async () => { + const zero = stubJsonFetch({ results: [] }); + await provider({ maxResults: 0 }).search('q'); + expect(readJsonBody(zero.calls[0]).max_results).toBe(1); + + const huge = stubJsonFetch({ results: [] }); + await provider({ maxResults: 99 }).search('q'); + expect(readJsonBody(huge.calls[0]).max_results).toBe(10); + }); + + it('truncates an over-long query', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + await provider().search('x'.repeat(600)); + + expect(readJsonBody(calls[0]).query).toHaveLength(500); + }); + + it('skips the request for a blank query', async () => { + const { calls } = stubJsonFetch({ results: [] }); + + const result = await provider().search(' '); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('without a query'); + expect(result.results).toEqual([]); + }); + + it('refuses to run without a token', async () => { + const { calls } = stubJsonFetch({ results: [] }); + const anonymous = createCodeBuddySearchProvider({ + resolveEndpoint: endpointResolver, + resolveToken: async () => null, + }); + + await expect(anonymous.search('q')).rejects.toThrow( + 'Authentication required', + ); + expect(calls).toHaveLength(0); + }); + + it('treats a whitespace-only token as no token', async () => { + const blank = createCodeBuddySearchProvider({ + resolveEndpoint: endpointResolver, + resolveToken: async () => ' ', + }); + + await expect(blank.search('q')).rejects.toThrow('Authentication required'); + }); + + it('reports an HTTP failure that carries a JSON message', async () => { + stubJsonFetch({ code: 7, msg: 'upstream down' }, 500); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search error: upstream down (code: 7)', + ); + }); + + it('names an unknown code when the message carries none', async () => { + stubJsonFetch({ msg: 'upstream down' }, 500); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search error: upstream down (code: unknown)', + ); + }); + + it('reports an HTTP failure with no usable message', async () => { + stubJsonFetch({ code: 7 }, 502); + await expect(provider().search('q')).rejects.toThrow('HTTP 502'); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('not json', 503)); + await expect(provider().search('q')).rejects.toThrow('HTTP 503'); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('', 504)); + await expect(provider().search('q')).rejects.toThrow('HTTP 504'); + }); + + it('falls back to the status when the error body cannot be read', async () => { + // A body that fails to decode must not mask the status. + stubFetch( + async () => + ({ + ok: false, + status: 500, + text: async () => { + throw new Error('body already consumed'); + }, + }) as unknown as Response, + ); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search failed with HTTP 500', + ); + }); + + it('abandons a request that outlasts the timeout', async () => { + // The endpoint is on the critical path of a model turn, so a hanging + // search has to fail on its own rather than wait for the socket. + stubFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + ); + + await expect(provider({ timeoutMs: 1_000 }).search('q')).rejects.toThrow( + 'aborted', + ); + }); + + it('reports an error code in a successful response', async () => { + stubJsonFetch({ code: 3, msg: 'bad request' }); + + await expect(provider().search('q')).rejects.toThrow( + 'CodeBuddy web search error: bad request', + ); + }); + + it('names an unknown error code when no message came back', async () => { + stubJsonFetch({ code: 3 }); + + await expect(provider().search('q')).rejects.toThrow('Unknown error'); + }); + + it('tolerates a payload with no results', async () => { + stubJsonFetch({}); + + const result = await provider().search('q'); + + expect(result.results).toEqual([]); + expect(result.content).toContain('returned no results'); + }); + + it('tolerates a results field that is not an array', async () => { + stubJsonFetch({ results: 'nope' }); + + const result = await provider().search('q'); + + expect(result.results).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// CodeBuddy fetch backend +// --------------------------------------------------------------------------- + +describe('CodeBuddy fetch provider', () => { + const provider = ( + options: { + maxContentLength?: number; + resolveHost?: HostResolver; + timeoutMs?: number; + } = {}, + ) => + createCodeBuddyFetchProvider({ + maxContentLength: options.maxContentLength, + resolveEndpoint: endpointResolver, + resolveHost: options.resolveHost ?? blockedResolver, + resolveToken: tokenResolver, + timeoutMs: options.timeoutMs, + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('normalizeFetchUrl', () => { + it('trims the url', () => { + expect(normalizeFetchUrl(' https://a.test/ ')).toBe('https://a.test/'); + }); + + it('upgrades plain http to https', () => { + expect(normalizeFetchUrl('http://a.test/page')).toBe( + 'https://a.test/page', + ); + }); + + it('rewrites a GitHub blob url to its raw counterpart', () => { + expect( + normalizeFetchUrl('https://github.com/org/repo/blob/main/README.md'), + ).toBe('https://raw.githubusercontent.com/org/repo/main/README.md'); + }); + + it('upgrades and rewrites in one pass', () => { + expect( + normalizeFetchUrl('http://github.com/org/repo/blob/main/a.ts'), + ).toBe('https://raw.githubusercontent.com/org/repo/main/a.ts'); + }); + + it('leaves a url that is neither plain http nor a blob alone', () => { + expect(normalizeFetchUrl('https://a.test/blob/not-github')).toBe( + 'https://a.test/blob/not-github', + ); + expect(normalizeFetchUrl('https://github.com/org/repo/tree/main')).toBe( + 'https://github.com/org/repo/tree/main', + ); + }); + }); + + it('identifies itself', () => { + expect(provider().id).toBe('codebuddy'); + }); + + it('posts to the agent-tool webfetch endpoint', async () => { + const { calls } = stubJsonFetch({ + content: 'page text', + content_type: 'text/markdown', + url: 'https://final.test/page', + }); + + const result = await provider().fetch({ url: 'https://a.test/page' }); + + expect(calls[0].url).toBe('https://agent.test/agenttool/v1/webfetch'); + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.cache).toBe('no-store'); + expect(readHeaders(calls[0]).get('authorization')).toBe(`Bearer ${TOKEN}`); + expect(result.url).toBe('https://final.test/page'); + expect(result.content).toContain( + 'Web fetch result for https://final.test/page:', + ); + expect(result.content).toContain('page text'); + }); + + it('sends the extraction hint, format, and bound the CLI sends', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ + prompt: ' the pricing tiers ', + url: 'https://a.test/page', + }); + + expect(readJsonBody(calls[0])).toEqual({ + format: 'markdown', + max_length: 100_000, + prompt: 'the pricing tiers', + timeout: 30, + url: 'https://a.test/page', + }); + }); + + it('asks for the whole page when the model gives no prompt', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ url: 'https://a.test/page' }); + + expect(readJsonBody(calls[0]).prompt).toBe(''); + }); + + it('truncates an over-long prompt', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ prompt: 'p'.repeat(600), url: 'https://a.test/' }); + + expect(readJsonBody(calls[0]).prompt).toHaveLength(500); + }); + + it('sends the normalized url and truncates an over-long one', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + await provider().fetch({ + url: `http://github.com/org/repo/blob/main/${'a'.repeat(3_000)}`, + }); + + const body = readJsonBody(calls[0]); + expect(body.url).toHaveLength(2_048); + expect( + String(body.url).startsWith('https://raw.githubusercontent.com/'), + ).toBe(true); + }); + + it('honours a configured content limit', async () => { + const { calls } = stubJsonFetch({ content: 'x'.repeat(200) }); + + const result = await provider({ maxContentLength: 10 }).fetch({ + url: 'https://a.test/', + }); + + expect(readJsonBody(calls[0]).max_length).toBe(10); + expect(result.content).toContain('x'.repeat(10)); + expect(result.content).not.toContain('x'.repeat(11)); + }); + + it('falls back to the requested url when the endpoint reports none', async () => { + stubJsonFetch({ content: 'page text', url: ' ' }); + + const result = await provider().fetch({ url: 'https://a.test/page' }); + + expect(result.url).toBe('https://a.test/page'); + }); + + it('skips the request for a blank url', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + + const result = await provider().fetch({ url: ' ' }); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('without a URL'); + }); + + it('refuses to run without a token', async () => { + const { calls } = stubJsonFetch({ content: 'page text' }); + const anonymous = createCodeBuddyFetchProvider({ + resolveEndpoint: endpointResolver, + resolveHost: publicResolver, + resolveToken: async () => ' ', + }); + + await expect(anonymous.fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'Authentication required', + ); + expect(calls).toHaveLength(0); + }); + + it('reports an HTTP failure that carries a JSON message', async () => { + stubJsonFetch({ code: 9, msg: 'rate limited' }, 429); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch error: rate limited (code: 9)', + ); + }); + + it('reports an HTTP failure with no usable message', async () => { + stubJsonFetch({ code: 9 }, 500); + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'HTTP 500', + ); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('not json', 502)); + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'HTTP 502', + ); + + vi.unstubAllGlobals(); + stubFetch(async () => makeTextResponse('', 503)); + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'HTTP 503', + ); + }); + + it('names an unknown code when the message carries none', async () => { + stubJsonFetch({ msg: 'rate limited' }, 429); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch error: rate limited (code: unknown)', + ); + }); + + it('falls back to the status when the error body cannot be read', async () => { + stubFetch( + async () => + ({ + ok: false, + status: 500, + text: async () => { + throw new Error('body already consumed'); + }, + }) as unknown as Response, + ); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch failed with HTTP 500', + ); + }); + + it('reports an error code in a successful response', async () => { + stubJsonFetch({ code: 4, msg: 'no such page' }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'CodeBuddy web fetch error: no such page', + ); + }); + + it('names an unknown error code when no message came back', async () => { + stubJsonFetch({ code: 4 }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'Unknown error', + ); + }); + + describe('resource classification', () => { + it.each([ + { content_type: '', kind: 'text' }, + { content_type: 'text/html; charset=utf-8', kind: 'text' }, + { content_type: 'TEXT/PLAIN', kind: 'text' }, + { content_type: 'application/json', kind: 'text' }, + { content_type: 'application/xml', kind: 'text' }, + { content_type: 'application/javascript', kind: 'text' }, + { content_type: 'image/png', kind: 'binary' }, + { content_type: 'application/pdf', kind: 'binary' }, + { content_type: 'application/zip', kind: 'binary' }, + { content_type: 'application/octet-stream', kind: 'binary' }, + { content_type: 'video/mp4', kind: 'binary' }, + { content_type: 'audio/mpeg', kind: 'binary' }, + ])('treats $content_type as $kind', async ({ content_type, kind }) => { + stubJsonFetch({ content: 'page text', content_type }); + + if (kind === 'text') { + await expect( + provider().fetch({ url: 'https://a.test/' }), + ).resolves.toEqual(expect.objectContaining({ url: 'https://a.test/' })); + + return; + } + + // The endpoint's reason survives into the combined failure. + await expect( + provider().fetch({ url: 'https://a.test/' }), + ).rejects.toThrow('non-text resource'); + }); + + it('treats a missing content type as text', async () => { + stubJsonFetch({ content: 'page text' }); + + await expect( + provider().fetch({ url: 'https://a.test/' }), + ).resolves.toMatchObject({ url: 'https://a.test/' }); + }); + }); + + it('rejects a body that is not a string', async () => { + // A payload whose `content` is not text has nothing to hand the model. + stubJsonFetch({ content: 42 }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'found no readable content', + ); + }); + + it('rejects an empty body from the endpoint', async () => { + stubJsonFetch({ content: ' \n ' }); + + await expect(provider().fetch({ url: 'https://a.test/' })).rejects.toThrow( + 'found no readable content', + ); + }); + + describe('local fallback', () => { + it('uses the local result when the endpoint fails', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + installTransport(({ respond }) => { + respond({ + body: 'local page text', + headers: { 'content-type': 'text/plain' }, + }); + }); + + const result = await provider({ resolveHost: publicResolver }).fetch({ + prompt: 'the release date', + url: 'http://a.test/page', + }); + + expect(result.content).toContain('local page text'); + // The local fetch gets the url as given, not the https upgrade. + expect(result.url).toBe('http://a.test/page'); + }); + + it('reports an endpoint failure that is not an Error', async () => { + stubFetch(async () => { + throw 'endpoint fell over'; + }); + // The endpoint is not awaited alone: on failure the local attempt is + // awaited too, so without a stubbed transport this resolves a real + // hostname and hangs until the test times out. + installTransport(({ request }) => { + request.emitError('endpoint fell over'); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow('endpoint fell over'); + }); + + it('abandons a request that outlasts the timeout', async () => { + stubFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject( + Object.assign(new Error('aborted'), { name: 'AbortError' }), + ), + ); + }), + ); + installTransport(({ request }) => { + request.emitError('connection reset'); + }); + + await expect( + provider({ timeoutMs: 1_000 }).fetch({ url: 'https://a.test/' }), + ).rejects.toThrow('aborted'); + }); + + it('reports a local failure that is not an Error', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + installTransport(({ request }) => { + request.emitError('local fell over'); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow('local fallback also failed: local fell over'); + }); + + it('reports both reasons when the local fallback also fails', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + installTransport(({ request }) => { + request.emitError(new Error('connection reset')); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow('local fallback also failed: connection reset'); + }); + + it('reports one reason when both attempts agree', async () => { + const reason = 'CodeBuddy web fetch error: boom (code: unknown)'; + stubJsonFetch({ msg: 'boom' }, 500); + installTransport(({ request }) => { + request.emitError(new Error(reason)); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow(reason); + }); + + it('reports a failed local fetch that returned a status', async () => { + stubJsonFetch({ msg: 'unauthorized' }, 401); + const calls = installTransport(({ respond }) => { + respond({ statusCode: 500 }); + }); + + await expect( + provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }), + ).rejects.toThrow(/local fallback also failed/); + expect(calls).toHaveLength(1); + }); + + it('prefers the endpoint result when both succeed', async () => { + stubJsonFetch({ content: 'endpoint text' }); + const calls = installTransport(({ respond }) => { + respond({ + body: 'local text', + headers: { 'content-type': 'text/plain' }, + }); + }); + + const result = await provider({ resolveHost: publicResolver }).fetch({ + url: 'https://a.test/', + }); + + expect(result.content).toContain('endpoint text'); + expect(result.content).not.toContain('local text'); + + // The abandoned local attempt is allowed to finish; wait for it so the + // request it makes is not recorded against the next test. + await settle(() => calls.length === 1); + expect(calls).toHaveLength(1); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Local fetch backend +// --------------------------------------------------------------------------- + +describe('local fetch provider', () => { + const provider = ( + options: { maxContentLength?: number; timeoutMs?: number } = {}, + ) => + createLocalFetchProvider({ + maxContentLength: options.maxContentLength, + resolveHost: publicResolver, + timeoutMs: options.timeoutMs, + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('identifies itself', () => { + expect(provider().id).toBe('local'); + }); + + it('reports a missing url without fetching', async () => { + const calls = installTransport(({ respond }) => respond()); + + const result = await provider().fetch({ url: ' ' }); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('without a URL'); + }); + + it('reports a url that is not absolute', async () => { + const calls = installTransport(({ respond }) => respond()); + + const result = await provider().fetch({ url: 'example.test/page' }); + + expect(calls).toHaveLength(0); + expect(result.content).toContain('is not a valid absolute URL'); + }); + + it.each(['file:///etc/passwd', 'data:text/plain,hello', 'ftp://a.test/f'])( + 'refuses the %s scheme', + async (url) => { + const calls = installTransport(({ respond }) => respond()); + + await expect(provider().fetch({ url })).rejects.toThrow( + 'Unsupported URL protocol', + ); + expect(calls).toHaveLength(0); + }, + ); + + it.each([ + 'http://localhost/admin', + 'http://api.localhost/admin', + 'http://127.0.0.1:8080/admin', + 'http://10.1.2.3/admin', + 'http://172.16.0.1/admin', + 'http://192.168.1.1/admin', + 'http://169.254.169.254/latest/meta-data', + 'http://0.0.0.0/admin', + 'http://[::1]/admin', + 'http://[fe80::1]/admin', + 'http://[fd00::1]/admin', + ])('refuses to fetch the private host %s', async (url) => { + const calls = installTransport(({ respond }) => respond()); + + await expect(provider().fetch({ url })).rejects.toThrow( + /Refusing to fetch a private or loopback address/, + ); + expect(calls).toHaveLength(0); + }); + + it('allows a public literal address', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await provider().fetch({ url: 'http://8.8.8.8/dns' }); + + expect(calls).toHaveLength(1); + expect(result.content).toContain('ok'); + }); + + it('allows a public IPv6 literal address', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await provider().fetch({ + url: 'http://[2001:4860:4860::8888]/dns', + }); + + expect(calls).toHaveLength(1); + // The brackets are stripped before the address is pinned. + expect(calls[0].options.lookup).toBeDefined(); + expect(result.content).toContain('ok'); + }); + + it('resolves the host through DNS when no resolver is injected', async () => { + const lookup = vi + .spyOn(dns, 'lookup') + .mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + ] as unknown as Awaited>); + installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await createLocalFetchProvider().fetch({ + url: 'http://a.test/page', + }); + + expect(lookup).toHaveBeenCalledWith('a.test', { all: true }); + expect(result.content).toContain('ok'); + }); + + it('allows a host whose addresses are all public', async () => { + const resolver: HostResolver = async () => [ + '93.184.216.34', + '93.184.216.35', + ]; + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await expect( + createLocalFetchProvider({ resolveHost: resolver }).fetch({ + url: 'http://a.test/', + }), + ).resolves.toMatchObject({ url: 'http://a.test/' }); + expect(calls).toHaveLength(1); + }); + + it('refuses a host with any private address', async () => { + const resolver: HostResolver = async () => ['93.184.216.34', '127.0.0.1']; + installTransport(({ respond }) => respond()); + + await expect( + createLocalFetchProvider({ resolveHost: resolver }).fetch({ + url: 'http://a.test/', + }), + ).rejects.toThrow('it resolves to the private address 127.0.0.1'); + }); + + it('trusts an operator-supplied override', async () => { + // Pinning a name to a private address is the point of an override. + const calls = installTransport(({ respond }) => + respond({ body: 'internal', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await createLocalFetchProvider({ + resolveHost: trustedResolver, + }).fetch({ url: 'http://internal.test/' }); + + expect(result.content).toContain('internal'); + expect(calls).toHaveLength(1); + }); + + it('reports a host that cannot be resolved', async () => { + const failing: HostResolver = async () => { + throw new Error('ENOTFOUND'); + }; + installTransport(({ respond }) => respond()); + + await expect( + createLocalFetchProvider({ resolveHost: failing }).fetch({ + url: 'http://a.test/', + }), + ).rejects.toThrow('could not resolve host: a.test'); + }); + + it('reports a host that resolves to nothing', async () => { + const empty: HostResolver = async () => []; + installTransport(({ respond }) => respond()); + + await expect( + createLocalFetchProvider({ resolveHost: empty }).fetch({ + url: 'http://a.test/', + }), + ).rejects.toThrow('could not resolve host: a.test'); + }); + + describe('the pinned request', () => { + it('pins the socket to the validated address', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: 'http://a.test/page?q=1' }); + + const [call] = calls; + expect(call.options.host).toBe('a.test'); + expect(call.options.method).toBe('GET'); + expect(call.options.path).toBe('/page?q=1'); + expect(call.options.port).toBe(80); + // The Host header keeps the real name, so virtual hosting survives. + expect(call.options.servername).toBeUndefined(); + expect(call.options.headers?.['User-Agent']).toContain('Mozilla/5.0'); + expect(call.options.headers?.Accept).toContain('text/html'); + expect(call.options.headers?.['Accept-Language']).toBe('en-US,en;q=0.9'); + + const lookup = call.options.lookup as PinnedLookup; + const seen: unknown[] = []; + lookup('a.test', { all: true }, (error, address) => + seen.push({ address, error }), + ); + lookup('a.test', {}, (error, address, family) => + seen.push({ address, error, family }), + ); + + expect(seen).toEqual([ + { address: [{ address: '93.184.216.34', family: 4 }], error: null }, + { address: '93.184.216.34', error: null, family: 4 }, + ]); + }); + + it('uses https with its port and servername', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: 'https://a.test/secure' }); + + expect(calls[0].options.port).toBe(443); + // TLS needs the real hostname for SNI and certificate checks. + expect(calls[0].options.servername).toBe('a.test'); + }); + + it('honours an explicit port', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: 'https://a.test:8443/secure' }); + + expect(calls[0].options.port).toBe(8443); + }); + }); + + it('returns the page text with the source url', async () => { + installTransport(({ respond }) => + respond({ body: 'page body', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await provider().fetch({ + prompt: 'the release date', + url: 'http://a.test/page', + }); + + expect(result.url).toBe('http://a.test/page'); + expect(result.content).toContain( + 'Web fetch result for http://a.test/page:', + ); + expect(result.content).toContain('Requested focus: the release date'); + expect(result.content).toContain('page body'); + }); + + it('converts HTML to readable text', async () => { + installTransport(({ respond }) => + respond({ + body: 'T

Hello

One & two

', + headers: { 'content-type': 'text/html' }, + }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('Hello'); + expect(result.content).toContain('One & two'); + expect(result.content).not.toContain('bad()'); + expect(result.content).not.toContain('hidden'); + expect(result.content).not.toContain('a{}'); + }); + + it('decodes numeric character references', async () => { + installTransport(({ respond }) => + respond({ + // `�` has no character of its own, so it decodes to a space. + body: '

AAB C'D�E

', + headers: { 'content-type': 'text/html' }, + }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain("AAB C'D E"); + }); + + it('reads a stream that delivers strings rather than buffers', async () => { + installTransport(({ respond }) => { + const response = respond({ endStream: false }); + setTimeout(() => { + response.emit('data', 'plain string body'); + response.emit('end'); + }, 0); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('plain string body'); + }); + + it('detects HTML when the server sends no content type', async () => { + installTransport(({ respond }) => + respond({ body: '

Hi

' }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('Hi'); + expect(result.content).not.toContain('

'); + }); + + it('keeps plain text as it is', async () => { + installTransport(({ respond }) => + respond({ + body: ' # Title\n\nNot html ', + headers: { 'content-type': 'text/markdown' }, + }), + ); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('# Title'); + }); + + it.each(['text/html', 'application/json', 'application/xml', 'text/plain'])( + 'reads the %s content type', + async (contentType) => { + installTransport(({ respond }) => + respond({ + body: 'body text', + headers: { 'content-type': contentType }, + }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).resolves.toMatchObject({ url: 'http://a.test/page' }); + }, + ); + + it.each(['image/png', 'application/pdf', 'application/octet-stream'])( + 'refuses the %s content type', + async (contentType) => { + installTransport(({ respond }) => + respond({ body: 'binary', headers: { 'content-type': contentType } }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow(`unsupported content type ${contentType}`); + }, + ); + + it('treats a missing content type as text', async () => { + // `isTextContentType` allows an empty type through, so the refusal's + // `|| 'unknown'` fallback is unreachable and is not asserted here. + installTransport(({ respond }) => respond({ body: 'page text' })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).resolves.toMatchObject({ url: 'http://a.test/page' }); + }); + + it('truncates a long page to the content limit', async () => { + installTransport(({ respond }) => + respond({ + body: 'a'.repeat(2_000), + headers: { 'content-type': 'text/plain' }, + }), + ); + + const result = await provider({ maxContentLength: 32 }).fetch({ + url: 'http://a.test/page', + }); + + expect(result.content).toContain('a'.repeat(32)); + expect(result.content).not.toContain('a'.repeat(33)); + }); + + it('reports a page with no readable content', async () => { + installTransport(({ respond }) => + respond({ body: ' \n\t ', headers: { 'content-type': 'text/plain' } }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('found no readable content at http://a.test/page'); + }); + + it.each([301, 302, 303, 307, 308])( + 'follows a %s redirect and re-validates the target', + async (status) => { + const calls = installTransport(({ call, respond }) => { + if (call === 0) { + respond({ + headers: { location: '/next' }, + statusCode: status, + }); + + return; + } + + respond({ + body: 'target text', + headers: { 'content-type': 'text/plain' }, + }); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(calls.map((call) => call.options.path)).toEqual([ + '/page', + '/next', + ]); + expect(result.url).toBe('http://a.test/next'); + expect(result.content).toContain('target text'); + }, + ); + + it('refuses a redirect that points at a private address', async () => { + const calls = installTransport(({ respond }) => + respond({ + headers: { location: 'http://localhost/admin' }, + statusCode: 302, + }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Refusing to fetch a private or loopback address'); + expect(calls).toHaveLength(1); + }); + + it('reports a redirect with no target', async () => { + installTransport(({ respond }) => respond({ statusCode: 302 })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('redirect with no target'); + }); + + it('uses the first of several location headers', async () => { + const calls = installTransport(({ call, respond }) => { + if (call === 0) { + respond({ + headers: { location: ['/first', '/second'] }, + statusCode: 301, + }); + + return; + } + + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(calls[1].options.path).toBe('/first'); + expect(result.url).toBe('http://a.test/first'); + }); + + it('gives up after too many redirects', async () => { + const calls = installTransport(({ respond }) => + respond({ headers: { location: '/next' }, statusCode: 302 }), + ); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('followed more than 5 redirects'); + // Six hops are attempted: the first request plus one per redirect slot. + expect(calls).toHaveLength(6); + }); + + it('reports an HTTP failure', async () => { + installTransport(({ respond }) => respond({ statusCode: 404 })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Web fetch failed with HTTP 404 for http://a.test/page'); + }); + + it('reports a response with no status code as a failure', async () => { + installTransport(({ respond }) => respond({ statusCode: undefined })); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Web fetch failed with HTTP 0'); + }); + + it('reports a socket error', async () => { + installTransport(({ request }) => { + request.emitError(new Error('socket hang up')); + }); + + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('socket hang up'); + }); + + it('gives up when the response never arrives', async () => { + installTransport(() => undefined); + + await expect( + provider({ timeoutMs: 1_000 }).fetch({ url: 'http://a.test/page' }), + ).rejects.toThrow('Web fetch timed out after 1000ms'); + }); + + it('stops reading a body that stalls mid-stream', async () => { + let response: FakeResponse | undefined; + installTransport(({ respond }) => { + response = respond({ body: 'start', endStream: false }); + }); + + // The response arrived but never ends, so only the socket timeout can + // release the reader. + const attempt = provider({ timeoutMs: 1_000 }).fetch({ + url: 'http://a.test/page', + }); + await tick(); + expect(response?.idleTimeoutListener).not.toBeNull(); + response?.idleTimeoutListener?.(); + + await expect(attempt).rejects.toThrow('Web fetch timed out after 1000ms'); + }); + + it('ignores a socket error that arrives after the response', async () => { + installTransport(({ request, respond }) => { + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }); + request.emitError(new Error('late error')); + }); + + // The request already answered, so a late error on the socket is not the + // caller's problem. + await expect( + provider().fetch({ url: 'http://a.test/page' }), + ).resolves.toMatchObject({ url: 'http://a.test/page' }); + }); + + it('ignores a second response on the same request', async () => { + installTransport(({ respond }) => { + respond({ body: 'first', headers: { 'content-type': 'text/plain' } }); + respond({ body: 'second', headers: { 'content-type': 'text/plain' } }); + }); + + const result = await provider().fetch({ url: 'http://a.test/page' }); + + expect(result.content).toContain('first'); + expect(result.content).not.toContain('second'); + }); + + it('truncates an over-long url before parsing it', async () => { + const calls = installTransport(({ respond }) => + respond({ body: 'ok', headers: { 'content-type': 'text/plain' } }), + ); + + await provider().fetch({ url: `http://a.test/${'p'.repeat(3_000)}` }); + + // The cap applies to the whole url, so the path keeps what is left. + expect(calls[0].options.path).toHaveLength( + 2_048 - 'http://a.test/'.length + 1, + ); + }); +}); diff --git a/tests/server/server-tools.test.ts b/tests/server/server-tools.test.ts index 4af81ee..f113720 100644 --- a/tests/server/server-tools.test.ts +++ b/tests/server/server-tools.test.ts @@ -1,2259 +1,1938 @@ -import fs from 'node:fs'; -import http from 'node:http'; -import path from 'node:path'; - -import { NextRequest } from 'next/server'; - -import { - getActiveConfig, - isWebFetchEnabled, - isWebSearchEnabled, - updateSettings, -} from '@/lib/server/domain/config'; -import { - createCodeBuddyFetchProvider, - normalizeFetchUrl, -} from '@/lib/server/search/providers/codebuddy-fetch'; -import { createCodeBuddySearchProvider } from '@/lib/server/search/providers/codebuddy-search'; import { - createLocalFetchProvider, - type HostResolver, -} from '@/lib/server/search/providers/local-fetch'; -import { - normalizeFetchBackend, - normalizeSearchBackend, - resetWebSearchProviders, - resolveFetchProvider, - resolveSearchProvider, - runWebFetch, - runWebSearch, -} from '@/lib/server/search'; -import { - createProxyContextFromCredential, - proxyChatCompletions, -} from '@/lib/server/proxy/codebuddy'; -import { - addCredential, - resetCredentialRuntimeState, -} from '@/lib/server/domain/credentials'; -import { resetUsageStats } from '@/lib/server/domain/stats'; + buildServerToolInvocation, + classifyServerToolDeclaration, + executeServerToolInvocations, + findServerToolDeclarations, + foldIntermediateTexts, + getForcedToolName, + attachServerToolExecutions, + getServerToolExecutions, + hasAmbiguousServerToolName, + hasExecutableServerTool, + readMaxUses, + parseBufferedPayload, + readBufferedChatCompletionPayload, + resolveServerToolBackends, + rewriteServerTools, + runServerToolTurn, + type ServerToolInvocation, + type ServerToolTurnOutcome, +} from '@/lib/server/proxy/server-tools'; +import { isEventStream } from '@/lib/server/shared/sse'; +import { updateSettings } from '@/lib/server/domain/config'; +import { resetWebSearchProviders } from '@/lib/server/search'; import type { ChatRequestBody } from '@/lib/server/proxy/codebuddy'; -import { - buildWebFetchToolDefinition, - buildWebSearchToolDefinition, - isMarkedServerTool, - normalizeToolName, - stripServerToolMarker, -} from '@/lib/server/search/tool'; -import { executeWebSearchLoop } from '@/lib/server/proxy/web-search-loop'; -import { translateResponsesToolsToChat } from '@/lib/server/proxy/responses'; -import { - pickCredentialToken, - resolveCodeBuddyToken, - withCodeBuddyToken, -} from '@/lib/server/search/token'; +import type { + WebFetchProvider, + WebSearchProvider, +} from '@/lib/server/search/types'; /** - * Settings persist to storage, and the storage directory defaults to the - * process working directory — so writes here would leak into any test file that - * runs later in the same worker. Pointing storage at a scratch directory keeps - * this file's settings to itself. + * The classifier is the whole fix, so it is tested on the distinction that was + * broken rather than on the happy path alone. + * + * `normalizeToolName` strips case and separators, so `WebSearch` — the ordinary + * function Claude Code declares and resolves itself — and `web_search` — the + * provider-executed server tool — become the same string. Matching on the name + * made the proxy answer Claude Code's own calls, so the `tool_use` block it was + * waiting for never arrived. Only the declared type can tell them apart. */ -const tempRootDir = path.join(process.cwd(), '.tmp-test-server-tools-root'); -const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); -const tempCredsDir = path.join(tempRootDir, '.codebuddy_creds'); -const cleanupDir = (): void => { - fs.rmSync(tempRootDir, { force: true, recursive: true }); +const SEARCH_TYPE = 'web_search_20250305'; +const FETCH_TYPE = 'web_fetch_20250910'; + +const claudeCodeWebSearch = { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, }; +const makeSearchProvider = ( + content = 'Search findings', +): WebSearchProvider => ({ + id: 'test-search', + search: async () => ({ + content, + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }), +}); + +const makeFetchProvider = (): WebFetchProvider => ({ + id: 'test-fetch', + fetch: async ({ url }) => ({ content: `Fetched ${url}`, url }), +}); + const makeJsonResponse = ( payload: Record, status = 200, -): Response => { - return new Response(JSON.stringify(payload), { +): Response => + new Response(JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json' }, }); -}; -type FetchCall = [string, RequestInit]; - -const lastFetchCall = (mock: ReturnType): FetchCall => - mock.mock.calls[mock.mock.calls.length - 1] as unknown as FetchCall; - -const stubFetch = (impl: (...args: unknown[]) => Promise) => { - const mock = vi.fn(impl as never); - - vi.stubGlobal('fetch', mock as unknown as typeof fetch); - - return mock; -}; +const assistantToolCall = ( + name: string, + args: string, + id = 'call_1', +): Record => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { id, type: 'function', function: { arguments: args, name } }, + ], + }, + }, + ], + usage: { total_tokens: 10 }, +}); -const withCredential = async (): Promise => { - await addCredential({ - bearer_token: 'cred-token', - created_at: Math.floor(Date.now() / 1000), - supported_models: 'glm-5.1', - user_id: 'tester', +/** An upstream that died behind a gateway: an HTML page on a success status. */ +const makeHtmlResponse = (): Response => + new Response('502 Bad Gateway', { + headers: { 'Content-Type': 'text/html' }, + status: 200, }); -}; /** - * Reads the loop's buffered payload, asserting the loop produced a response. + * The tool calls a response still asks the client to answer, and the + * `finish_reason` that introduces them. * - * `executeWebSearchLoop` legitimately returns a null response when no backend - * can run the declared tools, so assertions on the payload have to rule that - * out rather than reading through a nullable. + * Both halves of the same question: a `tool_use` left in the payload is a call + * the client has to resolve, and a `tool_calls` finish reason is what makes it + * wait for one. */ -const readPayload = async ( - result: { response: Response | null } | null, -): Promise> => { - if (!result?.response) { - throw new Error('Expected the server-tool loop to produce a response'); - } - - return (await result.response.json()) as Record; +const outstandingCalls = async ( + response: Response, +): Promise<{ + finishReason: string | null | undefined; + names: Array; +}> => { + const payload = (await response.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { tool_calls?: Array<{ function?: { name?: string } }> }; + }>; + }; + const choice = payload.choices?.[0]; + + return { + finishReason: choice?.finish_reason, + names: (choice?.message?.tool_calls ?? []).map( + (call) => call.function?.name, + ), + }; }; -describe('server tool backends', () => { - beforeEach(async () => { - resetWebSearchProviders(); - resetCredentialRuntimeState(); - cleanupDir(); - fs.mkdirSync(tempDataDir, { recursive: true }); - fs.mkdirSync(tempCredsDir, { recursive: true }); - vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); - }); - - afterEach(async () => { - resetWebSearchProviders(); - resetCredentialRuntimeState(); - resetUsageStats(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - cleanupDir(); - }); - - describe('backend normalization', () => { - it.each([ - ['codebuddy', 'codebuddy'], - [' searxng ', 'searxng'], - ['PASSTHROUGH', 'passthrough'], - ])('accepts %s as a search backend', (input, expected) => { - expect(normalizeSearchBackend(input)).toBe(expected); - }); - - it('falls back to the default for an unknown search backend', () => { - expect(normalizeSearchBackend('bogus')).toBe('searxng'); - expect(normalizeSearchBackend(undefined)).toBe('searxng'); - }); - - it('falls back to the default for an unknown fetch backend', () => { - expect(normalizeFetchBackend('bogus')).toBe('passthrough'); - expect(normalizeFetchBackend(null)).toBe('passthrough'); +describe('server tool classification', () => { + describe('declarations', () => { + it('recognises an Anthropic dated server tool type', () => { + expect( + classifyServerToolDeclaration({ + type: SEARCH_TYPE, + name: 'web_search', + max_uses: 8, + }), + ).toBe('web_search'); }); - it('accepts the previous backend names', () => { - // An upgrade must not silently change which side runs the tool: `local` - // and `none` are how these were saved before the rename. - expect(normalizeFetchBackend('local')).toBe('codebuddy2api'); - expect(normalizeFetchBackend('none')).toBe('passthrough'); - expect(normalizeSearchBackend('none')).toBe('passthrough'); + it('recognises a Responses preview server tool type', () => { + expect( + classifyServerToolDeclaration({ type: 'web_search_preview' }), + ).toBe('web_search'); }); - it('resolves the renamed backends to the same providers', () => { + it('recognises a fetch server tool type', () => { expect( - resolveFetchProvider('local', async () => 'https://cb.test')?.id, - ).toBe('local'); - expect( - resolveFetchProvider('codebuddy2api', async () => 'https://cb.test') - ?.id, - ).toBe('local'); + classifyServerToolDeclaration({ type: FETCH_TYPE, name: 'web_fetch' }), + ).toBe('web_fetch'); }); - it('resolves no provider when the backend is none', () => { - expect( - resolveSearchProvider('none', async () => 'https://cb.test'), - ).toBeNull(); + it('leaves an OpenAI function alone, even one named web_search', () => { expect( - resolveFetchProvider('none', async () => 'https://cb.test'), + classifyServerToolDeclaration({ + type: 'function', + function: { name: 'web_search' }, + }), ).toBeNull(); }); - it('resolves the local backend without any configuration', () => { + /** + * The regression. `WebSearch` normalises to the same string as the server + * tool, so a name-based test cannot tell them apart — and getting it wrong + * is what made Claude Code's own search silently stop working. + */ + it('leaves Claude Code’s own WebSearch function alone', () => { + // Anthropic's shorthand for a client function: no `type` at all. + expect(classifyServerToolDeclaration(claudeCodeWebSearch)).toBeNull(); + // OpenAI's spelling of the same thing. expect( - resolveFetchProvider('local', async () => 'https://cb.test')?.id, - ).toBe('local'); + classifyServerToolDeclaration({ + type: 'function', + function: { name: 'WebSearch' }, + }), + ).toBeNull(); }); - }); - describe('codebuddy search provider', () => { - it('posts to the agent-tool search path with the credential', async () => { - const mock = stubFetch(async () => - makeJsonResponse({ - provider: 'tencent', - results: [ - { - snippet: 'A snippet', - title: 'Docs', - url: 'https://docs.test', - }, - ], - total_results: 1, + it('classifies by type even when the name is the client’s spelling', () => { + expect( + classifyServerToolDeclaration({ + type: SEARCH_TYPE, + name: 'WebSearch', }), - ); - - const result = await createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test/', - resolveToken: async () => 'token-123', - }).search('weather today'); - - const [url, init] = lastFetchCall(mock); - expect(url).toBe('https://cb.test/agenttool/v1/search'); - expect(init.method).toBe('POST'); - expect(new Headers(init.headers).get('Authorization')).toBe( - 'Bearer token-123', - ); + ).toBe('web_search'); + }); - const body = JSON.parse(String(init.body)) as Record; - expect(body.query).toBe('weather today'); - expect(body.type).toBe('text2text'); - expect(body.max_results).toBe(5); + it('ignores a declaration that is not an object', () => { + expect(classifyServerToolDeclaration(null)).toBeNull(); + expect(classifyServerToolDeclaration('web_search')).toBeNull(); + }); - expect(result.results).toHaveLength(1); - expect(result.content).toContain('Docs'); - expect(result.content).toContain('https://docs.test'); + it('leaves an unrelated tool type alone', () => { + expect(classifyServerToolDeclaration({ type: 'mcp' })).toBeNull(); }); + }); - it('surfaces an error payload from the endpoint', async () => { - stubFetch(async () => makeJsonResponse({ code: 15001, msg: 'quota' })); + describe('findServerToolDeclarations', () => { + it('returns null when no provider-executed tool is declared', () => { + expect(findServerToolDeclarations([claudeCodeWebSearch])).toBeNull(); + expect(findServerToolDeclarations(undefined)).toBeNull(); + expect(findServerToolDeclarations([])).toBeNull(); + }); - await expect( - runWebSearch({ - backend: 'codebuddy', - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('CodeBuddy web search error: quota'); + it('reports which server tools were declared', () => { + expect( + findServerToolDeclarations([ + claudeCodeWebSearch, + { type: SEARCH_TYPE, name: 'web_search' }, + ]), + ).toEqual({ fetch: false, search: true }); + expect( + findServerToolDeclarations([ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + ]), + ).toEqual({ fetch: true, search: true }); }); + }); - it('reports a non-ok HTTP status', async () => { - stubFetch(async () => makeJsonResponse({ msg: 'nope' }, 502)); + describe('name collisions', () => { + it('does not treat the client’s WebSearch as a collision', () => { + // This pairing is Claude Code's normal shape in a single request, so + // calling it ambiguous disabled the server tool outright — the client + // then received a `web_search` tool_use it had no handler for. + expect( + hasAmbiguousServerToolName( + [{ type: SEARCH_TYPE, name: 'web_search' }, claudeCodeWebSearch], + 'web_search', + ), + ).toBe(false); + }); - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('CodeBuddy web search error: nope'); + it('flags a genuine name clash: the same name, two kinds', () => { + // Here the model really could not say which it meant, so neither runs. + expect( + hasAmbiguousServerToolName( + [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'web_search', input_schema: {} }, + ], + 'web_search', + ), + ).toBe(true); }); - it('refuses to call the endpoint without a token', async () => { - const mock = stubFetch(async () => makeJsonResponse({ results: [] })); + it('is not confused by a client function of another name', () => { + expect( + hasAmbiguousServerToolName( + [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'Read', input_schema: {} }, + ], + 'web_search', + ), + ).toBe(false); + }); - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => null, - }), - query: 'hello', - }), - ).resolves.toContain('Authentication required'); - expect(mock).not.toHaveBeenCalled(); + it('ignores a non-array tool list', () => { + expect(hasAmbiguousServerToolName(undefined, 'web_search')).toBe(false); }); - it('shapes results that lack snippets or titles', async () => { - stubFetch(async () => - makeJsonResponse({ - results: [ - { content: 'fallback snippet', url: 'https://a.test' }, - { title: 'Only title' }, - 'not-an-object', - ], - }), + /** + * Both would arrive upstream under one name and a model calling it has no + * way to say which it meant, so the call goes to the client rather than + * being guessed at. + */ + it('runs the server tool when the client’s own WebSearch is present', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search' }, claudeCodeWebSearch], + }); + + // The declared type already tells them apart, so the search runs and the + // client keeps its own tool. + expect(rewrite?.executable).toEqual({ fetch: false, search: true }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(true); + expect(rewrite?.classifyCall({ function: { name: 'web_search' } })).toBe( + 'web_search', ); + // The client's own tool, left for it: it declared `WebSearch` itself, so + // the respelled-name fallback is deliberately not registered. + expect(rewrite?.classifyCall({ function: { name: 'WebSearch' } })).toBe( + null, + ); + }); - const result = await createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).search('anything here'); + it('declines both tools when the names are genuinely identical', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'web_search', input_schema: {} }, + ], + }); - expect(result.results).toHaveLength(2); - expect(result.results[0]?.content).toBe('fallback snippet'); - expect(result.results[1]?.title).toBe('Only title'); - expect(result.content).toContain('(untitled)'); + expect(rewrite?.executable).toEqual({ fetch: false, search: false }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(false); }); - it('tolerates a payload with no results array', async () => { - stubFetch(async () => makeJsonResponse({})); - - const result = await createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).search('anything here'); + /** + * Ambiguity belongs to one name, so it is answered for one kind at a time. + * A single request-wide verdict withheld the search over a clash on the + * fetch's name, and the client got no server tool at all for a collision + * it had nothing to do with. + */ + it('withholds only the server tool whose name collides', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + // The client's own function, spelled exactly like the fetch tool: + // upstream cannot tell the two apart, so that one is left to it. + { name: 'web_fetch', input_schema: {} }, + ], + }); - expect(result.results).toEqual([]); - expect(result.content).toContain('returned no results'); + expect(rewrite?.executable).toEqual({ fetch: false, search: true }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(true); + // The search is still ours to run, and the fetch call goes back to the + // client — the one that declared a function of that name. + expect( + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_fetch' } }), + ).toBe(false); }); - it('surfaces an error payload with no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 7 })); + it('leaves the fetch runnable when the search name is what collides', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + { name: 'web_search', input_schema: {} }, + ], + }); - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('Unknown error'); + expect(rewrite?.executable).toEqual({ fetch: true, search: false }); + expect(hasExecutableServerTool(rewrite!.executable)).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_fetch' } }), + ).toBe(true); + expect( + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(false); }); + }); - it('falls back to the status when the error body is empty', async () => { - stubFetch(async () => new Response('', { status: 500 })); + describe('rewriteServerTools', () => { + it('swaps a runnable search declaration for a function upstream can call', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], + }); - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('failed with HTTP 500'); + expect(rewrite?.executable).toEqual({ fetch: false, search: true }); + expect(rewrite?.tools).toEqual([ + { + type: 'function', + function: expect.objectContaining({ name: 'web_search' }), + }, + ]); }); - it('falls back to the status when the error body is not JSON', async () => { - stubFetch(async () => new Response('502', { status: 502 })); + it('withdraws a declaration no backend can run, rather than offering it', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: null, + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], + }); - await expect( - runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: 'hello', - }), - ).resolves.toContain('failed with HTTP 502'); + expect(rewrite?.executable).toEqual({ fetch: false, search: false }); + // Offering the tool anyway would have the model call it and hand the + // client a `tool_use` for a name it declared as provider-executed and + // has no handler for: no search, and a turn it cannot complete. + expect(rewrite?.tools).toEqual([]); }); - it('short-circuits an empty query without calling the endpoint', async () => { - const mock = stubFetch(async () => makeJsonResponse({ results: [] })); - - const result = await runWebSearch({ - provider: createCodeBuddySearchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: ' ', + it('leaves a client function untouched in both tool lists', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { name: 'Read', input_schema: {} }, + ], }); - expect(result).toContain('without a query'); - expect(mock).not.toHaveBeenCalled(); + // The client's own function is forwarded verbatim; only the server + // declaration is rewritten. + expect(rewrite?.tools[0]).toEqual({ + type: 'function', + function: expect.objectContaining({ name: 'web_search' }), + }); + expect(rewrite?.tools[1]).toEqual({ name: 'Read', input_schema: {} }); }); - }); - describe('provider resolution', () => { - it('resolves the codebuddy search backend', () => { - expect( - resolveSearchProvider('codebuddy', async () => 'https://cb.test')?.id, - ).toBe('codebuddy'); - }); + /** + * The regression, from the other side. `WebSearch` is the tool *Claude + * Code* declares and resolves itself; normalised it is indistinguishable + * from the server tool, so a loose match here is what let the proxy answer + * calls the client meant to handle. + * + * The names the proxy injected are its own, so an exact match is all that + * is needed — and a miss means the call is the client's, which is the safe + * direction to be wrong in. + */ + it('recognises its own calls, including when upstream respells them', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search' }, + { type: FETCH_TYPE, name: 'web_fetch' }, + ], + }); - it('resolves the local fetch backend', () => { + // Ours, exactly as handed to upstream. expect( - resolveFetchProvider('local', async () => 'https://cb.test')?.id, - ).toBe('local'); - }); - - it('resolves the codebuddy fetch backend', () => { + rewrite?.isExecutableCall({ function: { name: 'web_search' } }), + ).toBe(true); expect( - resolveFetchProvider('codebuddy', async () => 'https://cb.test')?.id, - ).toBe('codebuddy'); - }); - }); + rewrite?.isExecutableCall({ function: { name: 'web_fetch' } }), + ).toBe(true); - describe('token resolution', () => { - it('prefers bearer_token', () => { + // Respelled by upstream. Safe to accept precisely because this request + // declares no client function of that name, so a call spelled `WebSearch` + // can only be our own tool coming back in another hand. expect( - pickCredentialToken({ access_token: 'a', bearer_token: 'b' }), - ).toBe('b'); - }); - - it('falls back to access_token when bearer_token is empty', () => { - // Empty must fall through, not just null: `??` would stop at the blank - // and report "no token" for a credential that has one. + rewrite?.isExecutableCall({ function: { name: 'WebSearch' } }), + ).toBe(true); expect( - pickCredentialToken({ access_token: 'fallback', bearer_token: '' }), - ).toBe('fallback'); - }); + rewrite?.isExecutableCall({ function: { name: 'Web Fetch' } }), + ).toBe(true); - it('falls back to access_token when bearer_token is missing', () => { - expect(pickCredentialToken({ access_token: 'fallback' })).toBe( - 'fallback', + // Still not ours. + expect(rewrite?.isExecutableCall({ function: { name: 'Read' } })).toBe( + false, ); }); + }); - it('treats a whitespace-only token as absent', () => { + describe('getForcedToolName', () => { + it('reads the name from either protocol shape', () => { + expect(getForcedToolName({ type: 'tool', name: 'web_search' })).toBe( + 'web_search', + ); expect( - pickCredentialToken({ access_token: ' ', bearer_token: ' ' }), - ).toBeNull(); + getForcedToolName({ + type: 'function', + function: { name: 'web_search' }, + }), + ).toBe('web_search'); }); - it('reports no token for an empty credential', () => { - expect(pickCredentialToken({})).toBeNull(); + it('returns null when no tool is forced', () => { + expect(getForcedToolName('auto')).toBeNull(); + expect(getForcedToolName({ type: 'auto' })).toBeNull(); }); }); +}); - describe('declaration stripping', () => { - it('keeps a client-declared web_search function when search cannot run', async () => { - // Search is selected but unconfigured (no SEARXNG_URL), so nothing can - // execute it — a client-owned function must survive untouched. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const clientTool = { - type: 'function', - function: { name: 'web_search', parameters: { type: 'object' } }, - }; - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [clientTool], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - // Nothing matched a server-tool declaration, so the request is left - // alone rather than rewritten. - expect(result).toBeNull(); - }); - - it.each(['passthrough', 'PASSTHROUGH', 'none'])( - 'passes through a server-declared search tool for %s', - async (backend) => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: backend, - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); +const body = { + messages: [{ role: 'user', content: 'when did it ship?' }], + model: 'test-model', +}; - expect(result?.response).toBeNull(); - expect(result?.body.tools).toEqual([ - { type: 'web_search_20260209', name: 'web_search' }, - ]); - }, +const makeRewrite = (searchProvider: WebSearchProvider | null) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider, + tools: [{ type: SEARCH_TYPE, name: 'web_search' }], + })!; + +describe('server tool turn', () => { + it('asks upstream once when the model does not call a server tool', async () => { + const callUpstream = vi.fn(async () => + makeJsonResponse({ + choices: [ + { finish_reason: 'stop', message: { content: 'Yesterday.' } }, + ], + }), ); - it('reads a query from the first non-empty string field', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - let searched = ''; - stubFetch(async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('searx.test')) { - searched = new URL(url).searchParams.get('q') ?? ''; - - return makeJsonResponse({ results: [] }); - } + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); + + expect(callUpstream).toHaveBeenCalledTimes(1); + expect(outcome.executions).toEqual([]); + // It answered outright, so there is no preamble — the answer stays in the + // payload where the renderer will find it. + expect(outcome.segments).toEqual([]); + expect((await outcome.response.json()).choices[0].message.content).toBe( + 'Yesterday.', + ); + }); - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'done' } }], - }); - }); + it('runs the search and asks upstream once more for the answer', async () => { + let calls = 0; + const callUpstream = vi.fn(async () => { + calls += 1; - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_alias', - function: { - // No known key: the fallback takes the first string. - arguments: '{"whatever":"unexpected shape"}', - name: 'web_search', - }, - }, - ], - }, - }, + { finish_reason: 'stop', message: { content: 'It shipped.' } }, ], - }), - }); - - expect(searched).toBe('unexpected shape'); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + }); }); - }); - describe('url normalization', () => { - it('upgrades http to https', () => { - expect(normalizeFetchUrl('http://a.test/page')).toBe( - 'https://a.test/page', - ); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('rewrites a github blob url to its raw equivalent', () => { - // Without this the fetch returns the GitHub HTML viewer rather than the - // file contents, which is almost never what was wanted. - expect( - normalizeFetchUrl('https://github.com/o/r/blob/main/README.md'), - ).toBe('https://raw.githubusercontent.com/o/r/main/README.md'); + expect(callUpstream).toHaveBeenCalledTimes(2); + expect(outcome.executions).toHaveLength(1); + expect(outcome.executions[0]).toMatchObject({ + input: { query: 'ship' }, + type: 'web_search', }); + expect((await outcome.response.json()).choices[0].message.content).toBe( + 'It shipped.', + ); + }); - it('rewrites a github blob url given over http', () => { - expect(normalizeFetchUrl('http://github.com/o/r/blob/main/a.ts')).toBe( - 'https://raw.githubusercontent.com/o/r/main/a.ts', - ); + it('appends the tool result to the follow-up request', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('leaves an ordinary url untouched', () => { - expect(normalizeFetchUrl('https://a.test/page')).toBe( - 'https://a.test/page', - ); + await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('leaves a non-blob github url untouched', () => { - expect(normalizeFetchUrl('https://github.com/o/r')).toBe( - 'https://github.com/o/r', - ); + const followUp = sentBodies[1] as { messages: unknown[] }; + expect(followUp.messages).toHaveLength(3); + expect(followUp.messages[1]).toMatchObject({ role: 'assistant' }); + expect(followUp.messages[2]).toMatchObject({ + role: 'tool', + tool_call_id: 'call_1', }); }); - describe('codebuddy fetch provider', () => { - it('posts the url and prompt to the webfetch path', async () => { - const mock = stubFetch(async () => - makeJsonResponse({ - content: '# Title\n\nBody text', - url: 'https://a.test', - }), - ); - - const result = await createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token-123', - }).fetch({ prompt: 'the release date', url: 'https://a.test/page' }); - - const [url, init] = lastFetchCall(mock); - expect(url).toBe('https://cb.test/agenttool/v1/webfetch'); + it('keeps the server tool callable so the model can refine its query', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - const body = JSON.parse(String(init.body)) as Record; - expect(body.url).toBe('https://a.test/page'); - expect(body.prompt).toBe('the release date'); - expect(body.format).toBe('markdown'); - - expect(result.content).toContain('Body text'); - expect(result.url).toBe('https://a.test'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('treats empty content as a failure', async () => { - stubFetch(async () => makeJsonResponse({ content: ' ' })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('no readable content'); + await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('refuses to call the endpoint without a token', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); + // Still there: whether to search again is the model's call, not ours. + expect((sentBodies[1] as { tools: unknown[] }).tools).toEqual([ + expect.objectContaining({ + function: expect.objectContaining({ name: 'web_search' }), + }), + ]); + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => null, - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('Authentication required'); - expect(mock).not.toHaveBeenCalled(); - }); + it('relaxes a tool_choice that would force another search', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - it('reports a non-ok HTTP status', async () => { - stubFetch(async () => makeJsonResponse({ msg: 'bad gateway' }, 502)); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('CodeBuddy web fetch error: bad gateway'); + await runServerToolTurn({ + body: { ...body, tool_choice: 'required' }, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('falls back to the status when the error body is empty', async () => { - stubFetch(async () => new Response('', { status: 503 })); + expect(sentBodies[1].tool_choice).toBe('auto'); + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('failed with HTTP 503'); - }); + it('loosens a forced server tool to let the model choose', async () => { + let calls = 0; + const sentBodies: Record[] = []; + const callUpstream = vi.fn(async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); - it('surfaces an error payload with no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 9 })); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('Unknown error'); + await runServerToolTurn({ + body: { + ...body, + tool_choice: { type: 'function', function: { name: 'web_search' } }, + }, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('falls back to the requested URL when none is returned', async () => { - stubFetch(async () => makeJsonResponse({ content: 'Body' })); + // Not `none`: the model may still want a second search, it just must not + // be compelled into one forever. + expect(sentBodies[1].tool_choice).toBe('auto'); + }); - const result = await createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).fetch({ url: 'https://a.test/page' }); + it('hands a failed upstream call back untouched', async () => { + const callUpstream = vi.fn(async () => + makeJsonResponse({ error: { message: 'rate limited' } }, 429), + ); - expect(result.url).toBe('https://a.test/page'); - expect(result.content).toContain('Body'); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('reports an HTTP error carrying no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 1 }, 500)); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('failed with HTTP 500'); - }); + expect(callUpstream).toHaveBeenCalledTimes(1); + expect(outcome.executions).toEqual([]); + expect(outcome.response.status).toBe(429); + }); - it('falls back to Unknown error when the code carries no message', async () => { - stubFetch(async () => makeJsonResponse({ code: 42 })); + it('reports the invocations it is about to run', async () => { + let calls = 0; + const invocations: ServerToolInvocation[] = []; + const callUpstream = vi.fn(async () => { + calls += 1; - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('Unknown error'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"ship"}')) + : makeJsonResponse({ choices: [] }); }); - it('treats a non-string content field as empty', async () => { - stubFetch(async () => makeJsonResponse({ content: { nope: true } })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test' }, - }), - ).resolves.toContain('no readable content'); + await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + onCall: (invocation) => invocations.push(invocation), + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('omits the prompt from the result when none was given', async () => { - stubFetch(async () => makeJsonResponse({ content: 'Body text' })); + expect(invocations).toEqual([ + { id: 'call_1', input: { query: 'ship' }, type: 'web_search' }, + ]); + }); - const result = await createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }).fetch({ url: 'https://a.test/page' }); + it('keeps a client-owned call for the client to resolve', async () => { + let calls = 0; + const callUpstream = vi.fn(async () => { + calls += 1; - expect(result.content).not.toContain('Requested focus'); + return calls === 1 + ? makeJsonResponse(assistantToolCall('Read', '{"path":"/tmp/a"}')) + : makeJsonResponse({ choices: [] }); }); - it('falls back to a local fetch when the endpoint fails', async () => { - // This is the behaviour that keeps data flowing: the CLI races the - // endpoint against a local fetch, so an endpoint failure alone must not - // lose the page. - // - // The fallback uses `node:http` rather than `fetch`, so it cannot be - // stubbed — it gets a real local server and an injected resolver that - // points the hostname at it. - const server = http.createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('local copy of the page'); - }); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); + const outcome = await runServerToolTurn({ + body, + callUpstream, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); - if (!address || typeof address === 'string') { - throw new Error('failed to start test server'); - } + // Not ours, so nothing runs and the call goes back exactly as it arrived. + expect(callUpstream).toHaveBeenCalledTimes(1); + expect(outcome.executions).toEqual([]); + }); +}); - stubFetch(async () => makeJsonResponse({ msg: 'endpoint down' }, 502)); +describe('foldIntermediateTexts', () => { + it('puts prose from earlier hops ahead of the closing message', () => { + const folded = foldIntermediateTexts( + { choices: [{ message: { content: 'final' } }] }, + ['first', 'second'], + ); - try { - const resolveHost: HostResolver = Object.assign( - async () => ['127.0.0.1'], - { trusted: true }, - ); + expect(folded.choices?.[0]?.message?.content).toBe( + 'first\n\nsecond\n\nfinal', + ); + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveHost, - resolveToken: async () => 'token', - }), - query: { url: `http://fallback.test:${address.port}/page` }, - }), - ).resolves.toContain('local copy of the page'); - } finally { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - }); - - it('reports one reason when both failures agree', async () => { - // The host cannot resolve, so the local fallback fails with exactly the - // message the endpoint reports. Repeating it would just be noise. - const reason = 'Web fetch could not resolve host: a.test'; - stubFetch(async () => { - throw new Error(reason); - }); + it('leaves the payload alone when there is nothing to fold', () => { + const payload = { choices: [{ message: { content: 'final' } }] }; - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain(`Web fetch failed: ${reason}.`); + expect(foldIntermediateTexts(payload, [])).toBe(payload); + expect(foldIntermediateTexts({}, ['text'])).toEqual({}); + }); +}); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.not.toContain('local fallback also failed'); +describe('server tool plumbing', () => { + describe('buildServerToolInvocation', () => { + it('reads a fetch call and keeps the tool call id', () => { + expect( + buildServerToolInvocation( + { + id: 'call_fetch', + function: { + arguments: '{"url":"https://a.test","prompt":"the price"}', + name: 'web_fetch', + }, + }, + 'web_fetch', + 0, + ), + ).toEqual({ + id: 'call_fetch', + input: { prompt: 'the price', url: 'https://a.test' }, + type: 'web_fetch', + }); }); - it('handles a non-Error failure from the fallback', async () => { - stubFetch(async () => { - throw 'endpoint string failure'; + it('falls back to a positional id when the model sends none', () => { + expect( + buildServerToolInvocation( + { function: { name: 'web_search' } }, + 'web_search', + 3, + ), + ).toEqual({ + id: 'server_tool_3', + input: { query: '' }, + type: 'web_search', }); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain('local fallback also failed'); }); + }); - it('reports both reasons when the endpoint and the fallback fail', async () => { - stubFetch(async () => makeJsonResponse({ msg: 'endpoint down' }, 502)); + describe('executeServerToolInvocations', () => { + it('runs a fetch through the fetch provider', async () => { + const results = await executeServerToolInvocations({ + fetchProvider: makeFetchProvider(), + invocations: [ + { + id: 'call_fetch', + input: { url: 'https://a.test' }, + type: 'web_fetch', + }, + ], + searchProvider: null, + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain('local fallback also failed'); + expect(results).toEqual([ + { + content: 'Fetched https://a.test', + execution: { + id: 'call_fetch', + input: { url: 'https://a.test' }, + result: { + content: 'Fetched https://a.test', + url: 'https://a.test', + }, + type: 'web_fetch', + }, + tool_call_id: 'call_fetch', + }, + ]); }); - it('refuses a non-text response', async () => { - stubFetch(async () => - makeJsonResponse({ - content: 'binary bytes', - content_type: 'application/pdf', - }), - ); + it('reports an unconfigured fetch backend as text rather than failing', async () => { + const results = await executeServerToolInvocations({ + fetchProvider: null, + invocations: [ + { + id: 'call_fetch', + input: { url: 'https://a.test' }, + type: 'web_fetch', + }, + ], + searchProvider: null, + }); - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/f.pdf' }, - }), - ).resolves.toContain('non-text resource'); + expect(results[0]?.content).toContain('no web fetch backend'); }); + }); - it('refuses an image response', async () => { - stubFetch(async () => - makeJsonResponse({ content: 'bytes', content_type: 'image/png' }), - ); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/p.png' }, - }), - ).resolves.toContain('non-text resource'); + describe('parseBufferedPayload', () => { + it('returns the parsed payload', () => { + expect(parseBufferedPayload('{"choices":[]}', true)).toEqual({ + choices: [], + }); }); - it('accepts a text response with a charset suffix', async () => { - stubFetch(async () => - makeJsonResponse({ - content: 'Hello there', - content_type: 'text/html; charset=utf-8', - }), - ); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: 'https://a.test/page' }, - }), - ).resolves.toContain('Hello there'); + it('rethrows a malformed body the upstream called successful', () => { + expect(() => parseBufferedPayload('not json', true)).toThrow(); }); - it('reports a missing URL without calling the endpoint', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); - - await expect( - runWebFetch({ - provider: createCodeBuddyFetchProvider({ - resolveEndpoint: async () => 'https://cb.test', - resolveToken: async () => 'token', - }), - query: { url: ' ' }, - }), - ).resolves.toContain('without a URL'); - expect(mock).not.toHaveBeenCalled(); + it('tolerates a malformed body on a failure status', () => { + expect(parseBufferedPayload('not json', false)).toEqual({}); }); }); - describe('local fetch provider', () => { - /** - * A real server, because the backend no longer goes through `globalThis.fetch`: - * it resolves the host itself and pins the socket to the validated address, - * so a stubbed `fetch` cannot exercise the SSRF path at all. - */ - let server: http.Server; - let baseUrl: string; - let handler: (req: http.IncomingMessage, res: http.ServerResponse) => void; - - beforeEach(async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('ok'); - }; - server = http.createServer((req, res) => handler(req, res)); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', resolve); + describe('readBufferedChatCompletionPayload', () => { + it('falls back to the raw body when a failure carries no message', () => { + // A payload with only a code has nothing to extract, and the JSON is + // still the only record of what happened, so it beats the generic + // "Upstream request failed" — which says only that something failed. + const response = new Response('{"error":{"code":6004}}', { + headers: { 'Content-Type': 'application/json' }, + status: 429, }); - const address = server.address(); - - if (!address || typeof address === 'string') { - throw new Error('failed to start test server'); - } - // A name that need not resolve anywhere: the injected resolver answers for - // it, so no DNS or network access is involved. It must not be `127.0.0.1`, - // which the private-address check refuses before it proves anything. - baseUrl = `http://public.test:${address.port}`; + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ + error: { message: '{"error":{"code":6004}}', status: 429 }, + }); }); - afterEach(async () => { - await new Promise((resolve) => { - server.close(() => resolve()); + it('uses the generic message when the failure body is empty', () => { + const response = new Response('', { + headers: { 'Content-Type': 'application/json' }, + status: 429, }); - }); - /** - * Resolves the test hostname to the loopback address the server is bound to. - * - * The address itself is refused by the private-range check, so this override - * is what lets a test reach a local server while still exercising the real - * resolve → validate → pin path. Pinning then connects to 127.0.0.1 while the - * request still names the public hostname. - */ - const resolveToLocalServer: HostResolver = Object.assign( - async () => ['127.0.0.1'], - { trusted: true }, - ); + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ + error: { + message: 'Upstream request failed with status 429', + status: 429, + }, + }); + }); - /** - * A resolver whose answers are validated. - * - * The provider only applies the private-address check to answers coming from - * its DNS resolver — an injected resolver is an explicit operator pin, and - * pinning a name to a private address is legitimate. These wrappers produce - * values that look like DNS answers so the validation path is exercised. - */ - const dnsReturning = (addresses: string[]): HostResolver => - Object.assign(async () => addresses, { trusted: false }); - - const fetchWith = ( - url: string, - options: { resolveHost?: HostResolver } = {}, - ) => - runWebFetch({ - provider: createLocalFetchProvider({ - resolveHost: options.resolveHost ?? resolveToLocalServer, - }), - query: { url }, + it('prefers the nested upstream message over the generic one', () => { + const response = new Response('{"error":{"message":"quota exceeded"}}', { + headers: { 'Content-Type': 'application/json' }, + status: 429, }); - it('converts html to text', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/html' }); - res.end( - 'T

Hello

World & friends

', - ); - }; + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ + error: { message: 'quota exceeded', status: 429 }, + }); + }); - const result = await createLocalFetchProvider({ - resolveHost: resolveToLocalServer, - }).fetch({ url: `${baseUrl}/page` }); + it('reports an error payload on a successful status', () => { + const response = new Response('{"error":{"message":"nope"}}', { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }); - expect(result.content).toContain('Hello'); - expect(result.content).toContain('World & friends'); - expect(result.content).not.toContain('bad()'); - expect(result.content).not.toContain('

'); + return expect( + readBufferedChatCompletionPayload(response), + ).resolves.toEqual({ error: { message: 'nope' } }); }); + }); - it('preserves the host header while pinning the address', async () => { - let seenHost: string | undefined; - handler = (req, res) => { - seenHost = req.headers.host; - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('ok'); - }; + describe('getServerToolExecutions', () => { + it('reports none for a response that ran no tools', () => { + expect(getServerToolExecutions(new Response('{}'))).toEqual([]); + }); + }); - await fetchWith(`${baseUrl}/page`); + describe('isEventStream', () => { + it('tells an SSE response from a buffered one', () => { + expect( + isEventStream( + new Response('{}', { + headers: { 'Content-Type': 'text/event-stream' }, + }), + ), + ).toBe(true); + expect( + isEventStream( + new Response('{}', { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ).toBe(false); + // No content-type at all: upstream omitted it on a failure. + expect(isEventStream(new Response('{}'))).toBe(false); + }); + }); - // The connection is pinned to the validated IP, but the request still has - // to name the original host so virtual-host routing and TLS SNI work. - expect(seenHost).toBe(`public.test:${new URL(baseUrl).port}`); + describe('resolveServerToolBackends', () => { + afterEach(() => { + vi.restoreAllMocks(); + resetWebSearchProviders(); }); - it('refuses a private address before connecting', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); + it('resolves nothing when both backends are passthrough', async () => { + await updateSettings({ + CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', + CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', + }); - await expect(fetchWith('http://127.0.0.1/admin')).resolves.toContain( - 'private or loopback', - ); - expect(mock).not.toHaveBeenCalled(); + await expect(resolveServerToolBackends()).resolves.toEqual({ + fetchProvider: null, + searchProvider: null, + }); }); - it('refuses a hostname that resolves to a private address', async () => { - // A public-looking name whose resolution is a private address must still be - // refused: validating only the hostname string would let it through, and - // pinning means the connection would then go to the loopback address. - await expect( - fetchWith('http://private.test/page', { - resolveHost: dnsReturning(['127.0.0.1']), - }), - ).resolves.toContain('resolves to the private address'); - }); + it('resolves the configured backends', async () => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ + CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', + CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', + }); - it('refuses a non-http scheme', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); + const { fetchProvider, searchProvider } = + await resolveServerToolBackends(); + delete process.env.SEARXNG_URL; - await expect(fetchWith('file:///etc/passwd')).resolves.toContain( - 'Unsupported URL protocol', - ); - expect(mock).not.toHaveBeenCalled(); + expect(fetchProvider?.id).toBe('local'); + expect(searchProvider?.id).toBe('searxng'); }); + }); - it('refuses a redirect onto a private address', async () => { - handler = (_req, res) => { - res.writeHead(302, { location: 'http://169.254.169.254/latest' }); - res.end(); - }; + describe('relaxToolChoice', () => { + const runWith = async (toolChoice: unknown) => { + const sentBodies: ChatRequestBody[] = []; + let calls = 0; - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'private or loopback', - ); - }); + await runServerToolTurn({ + body: { + ...body, + tool_choice: toolChoice, + } as never, + callUpstream: async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse( + assistantToolCall('web_search', '{"query":"ship"}'), + ) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); - it('rejects an unsupported content type', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'application/pdf' }); - res.end('binary'); - }; + return sentBodies[1]?.tool_choice; + }; - await expect(fetchWith(`${baseUrl}/f.pdf`)).resolves.toContain( - 'unsupported content type', - ); + it('leaves an unrelated tool_choice alone', async () => { + await expect(runWith('auto')).resolves.toBe('auto'); }); - it('reports an invalid URL without connecting', async () => { - await expect(fetchWith('not-a-url')).resolves.toContain( - 'not a valid absolute URL', - ); + it('leaves a forced client tool alone', async () => { + await expect( + runWith({ type: 'function', function: { name: 'Read' } }), + ).resolves.toEqual({ type: 'function', function: { name: 'Read' } }); }); - it('reports a blank URL without connecting', async () => { - await expect(fetchWith(' ')).resolves.toContain('without a URL'); + it('carries no tool_choice through when none was set', async () => { + await expect(runWith(undefined)).resolves.toBeUndefined(); }); + }); - it('follows a redirect whose location header is an array', async () => { - let call = 0; - handler = (_req, res) => { - call += 1; - - if (call === 1) { - // Node exposes repeated headers as an array, and only the first is - // used. `setHeader` accepts that array shape, but it has to run - // before `writeHead` commits the headers. - res.setHeader('location', [`${baseUrl}/final`, '/ignored']); - res.writeHead(302); - res.end(); - return; - } - - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('final body'); - }; - - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'final body', - ); + describe('foldIntermediateTexts', () => { + it('ignores extra text when the payload has no choices', () => { + expect(foldIntermediateTexts({ choices: [] }, ['orphan'])).toEqual({ + choices: [], + }); }); + }); +}); - it('refuses localhost and other reserved hosts', async () => { - const mock = stubFetch(async () => makeJsonResponse({ content: 'x' })); - - for (const url of [ - 'http://localhost/admin', - 'http://internal.localhost/admin', - 'http://[::1]/admin', - 'http://[fd00::1]/admin', - 'http://0.0.0.0/admin', - 'http://172.20.0.1/admin', - 'http://10.1.2.3/admin', - 'http://192.168.1.1/admin', - ]) { - await expect(fetchWith(url)).resolves.toContain('private or loopback'); - } +describe('server tool edge cases', () => { + it('builds a search invocation from a call with no name at all', () => { + expect(buildServerToolInvocation({}, 'web_search', 2)).toEqual({ + id: 'server_tool_2', + input: { query: '' }, + type: 'web_search', + }); + }); - expect(mock).not.toHaveBeenCalled(); + it('reports no executions when a turn ran but upstream sent no message', async () => { + let calls = 0; + outcomeCalls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return calls === 1 + ? makeJsonResponse({ choices: [{ finish_reason: 'stop' }] }) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('reports a redirect with no target', async () => { - handler = (_req, res) => { - res.writeHead(302); - res.end(); - }; + expect(outcome.executions).toEqual([]); + expect(outcome.segments).toEqual([]); + }); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'redirect with no target', - ); + it('runs a turn whose body carries no messages', async () => { + let calls = 0; + const sentBodies: ChatRequestBody[] = []; + const outcome = await runServerToolTurn({ + body: { model: 'test-model', stream: false } as ChatRequestBody, + callUpstream: async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse( + assistantToolCall('web_search', '{"query":"ship"}'), + ) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('reports too many redirects', async () => { - handler = (_req, res) => { - res.writeHead(302, { location: `${baseUrl}/next` }); - res.end(); - }; + expect(outcome.executions).toHaveLength(1); + // Only the assistant message and the tool result were appended. + expect((sentBodies[1] as { messages: unknown[] }).messages).toHaveLength(2); + }); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'more than 5 redirects', - ); + it('leaves a tool_choice with no name to the follow-up', async () => { + let calls = 0; + const sentBodies: ChatRequestBody[] = []; + + await runServerToolTurn({ + body: { ...body, tool_choice: { type: 'auto' } } as never, + callUpstream: async (nextBody) => { + calls += 1; + sentBodies.push(nextBody); + + return calls === 1 + ? makeJsonResponse( + assistantToolCall('web_search', '{"query":"ship"}'), + ) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('reports an HTTP error status', async () => { - handler = (_req, res) => { - res.writeHead(404, { 'content-type': 'text/plain' }); - res.end('gone'); - }; + expect(sentBodies[1].tool_choice).toEqual({ type: 'auto' }); + }); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'Web fetch failed with HTTP 404', - ); - }); + it('folds extra text into a choice that carries no message', () => { + const folded = foldIntermediateTexts({ choices: [{ index: 0 }] }, [ + 'earlier', + ]); - it('reports a page with no readable text', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/html' }); - res.end(' '); - }; + expect(folded.choices?.[0]?.message?.content).toBe('earlier'); + }); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'no readable content', - ); - }); + it('records nothing when a turn attaches no executions', () => { + const response = new Response('{}'); - it('follows a redirect to another public host', async () => { - let call = 0; - handler = (_req, res) => { - call += 1; + expect(attachServerToolExecutions(response, [])).toBe(response); + expect(getServerToolExecutions(response)).toEqual([]); + }); +}); - if (call === 1) { - res.writeHead(301, { location: `${baseUrl}/final` }); - res.end(); - return; - } +describe('budget and call-matching edges', () => { + it('falls back to the default budget when there is no tool list', () => { + expect(readMaxUses(undefined, 'web_search')).toBe(5); + expect(readMaxUses('not-an-array', 'web_fetch')).toBe(5); + }); - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

Final page

'); - }; + it('ignores a declared budget that is not a usable number', () => { + const tools = [{ type: SEARCH_TYPE, name: 'web_search', max_uses: 'many' }]; - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'Final page', - ); - }); + expect(readMaxUses(tools, 'web_search')).toBe(5); + }); - it('decodes entities when converting html', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/html' }); - res.end('

a & b <c> A

'); - }; + it('claims nothing for a call that has no name', () => { + const rewrite = makeRewrite(makeSearchProvider()); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'a & b A', - ); - }); + expect(rewrite?.classifyCall({})).toBeNull(); + expect(rewrite?.classifyCall({ function: {} })).toBeNull(); + }); +}); - it('passes plain text through unchanged', async () => { - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('a & b'); - }; +/** + * The searches have already run and been billed by the time the closing hop + * fails, so they must not vanish along with it. + */ +it('keeps the searches and the usage when the closing hop fails', async () => { + closingCalls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + const hop = + closingCalls++ === 0 + ? assistantToolCall('web_search', '{"query":"q"}') + : { error: { message: 'rate limited' } }; + + return makeJsonResponse(hop, closingCalls === 1 ? 200 : 429); + }, + fetchProvider: null, + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: 1 }], + }), + searchProvider: makeSearchProvider(), + }); - // Entities are only decoded on the HTML path: a text/plain body is literal - // text, and decoding it would corrupt the content. - await expect(fetchWith(`${baseUrl}/t.txt`)).resolves.toContain( - 'a & b', - ); - }); + expect(outcome.executions).toHaveLength(1); + // Accrued before the failure, and the only record the client gets. + expect(outcome.usage).toEqual({ total_tokens: 10 }); + expect(getServerToolExecutions(outcome.response)).toHaveLength(1); +}); - it('reports a hostname whose DNS lookup fails', async () => { - await expect( - fetchWith('http://nx.test/page', { - resolveHost: async () => { - throw new Error('dns boom'); +/** + * A hop that asks for a server tool *and* something the client owns. The + * search runs, but the turn cannot continue here — the client has to answer + * its own call first — so the findings go back with that call outstanding. + */ +it('runs the search but hands a client call back unresolved', async () => { + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => + makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me check that file first.', + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"q"}', + name: 'web_search', + }, + }, + { + id: 'call_2', + type: 'function', + function: { arguments: '{}', name: 'Read' }, + }, + ], + }, }, - }), - ).resolves.toContain('could not resolve host'); - }); + ], + }), + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); - it('treats a missing content type as text', async () => { - handler = (_req, res) => { - res.writeHead(200); - res.end('plain body'); + // One search ran, and the client's call survives for it to answer. + expect(outcome.executions).toHaveLength(1); + // The prose that led to the search stays with it, as its own segment. + expect(outcome.segments).toEqual([ + { + executions: outcome.executions, + reasoning: '', + text: 'Let me check that file first.', + }, + ]); + + const payload = (await outcome.response.json()) as { + choices: Array<{ + finish_reason: string | null; + message: { + content: string | null; + tool_calls?: Array<{ function?: { name?: string } }>; }; + }>; + }; + + expect( + payload.choices[0]?.message.tool_calls?.map((c) => c.function?.name), + ).toEqual(['Read']); + expect(payload.choices[0]?.finish_reason).toBe('tool_calls'); + // The prose travels as the preamble, not duplicated on the payload. + expect(payload.choices[0]?.message.content).toBeNull(); +}); - await expect(fetchWith(`${baseUrl}/page`)).resolves.toContain( - 'plain body', - ); - }); +it('copes with a hop that carries no message at all', async () => { + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => makeJsonResponse({ choices: [{}] }), + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + }); - it('reports a connection dropped mid-body', async () => { - // The socket is destroyed instead of left hanging: an abrupt close fails - // fast and deterministically, whereas asserting on a stalled body would - // depend on the idle timeout firing within the test's own time budget. - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.write('partial'); - res.destroy(); - }; + // No call to answer and no prose to keep: the hop is the turn. + expect(outcome.executions).toEqual([]); + expect(outcome.segments).toEqual([]); +}); - const result = await runWebFetch({ - provider: createLocalFetchProvider({ - resolveHost: resolveToLocalServer, - }), - query: { url: `${baseUrl}/dropped` }, +describe('server tool loop', () => { + /** Answers each hop in turn, so a test can script a multi-hop model. */ + const scripted = ( + hops: Array>, + maxUses = 5, + ): { + run: () => Promise; + sentBodies: () => ChatRequestBody[]; + } => { + const sentBodies: ChatRequestBody[] = []; + let calls = 0; + + const run = (): Promise => + runServerToolTurn({ + body, + callUpstream: async (nextBody) => { + sentBodies.push(nextBody); + const hop = hops[Math.min(calls, hops.length - 1)]; + calls += 1; + + return makeJsonResponse(hop); + }, + fetchProvider: null, + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: maxUses }], + })!, + searchProvider: makeSearchProvider(), }); - expect(result).toContain('Web fetch failed'); - }); - - it('stops reading once the body cap is reached', async () => { - const chunk = 'y'.repeat(1000); - // Far more than the cap, in chunks, and then the server is done: the body - // is bounded by the reader, not by the server's willingness to stop. - handler = (_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - for (let index = 0; index < 500; index += 1) { - res.write(chunk); - } - res.end(); - }; + return { run, sentBodies: () => sentBodies }; + }; + + const ask = (query: string, id = 'call_1') => + assistantToolCall('web_search', `{"query":"${query}"}`, id); + + /** + * Both kinds runnable, which is the only shape in which a hop can afford + * nothing while the turn still has allowance left: with one kind alone, the + * hop that asks for it either runs or ends the turn. + * + * The last hop repeats forever, so a model that never stops asking is + * scripted by giving it a single hop. + */ + const scriptedBoth = ( + hops: Array>, + budgets: { fetch: number; search: number } = { fetch: 5, search: 5 }, + ): { + run: () => Promise; + upstreamCalls: () => number; + } => { + let calls = 0; + + const run = (): Promise => + runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + // A ceiling rather than an endless script. A turn that stops + // terminating has to fail this test, not hang the suite with it. + if (calls > 25) { + throw new Error( + `the turn did not terminate: ${calls} upstream calls`, + ); + } - const result = await runWebFetch({ - provider: createLocalFetchProvider({ - maxContentLength: 2000, - resolveHost: resolveToLocalServer, + return makeJsonResponse(hops[Math.min(calls - 1, hops.length - 1)]); + }, + fetchProvider: makeFetchProvider(), + rewrite: rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: budgets.search }, + { type: FETCH_TYPE, name: 'web_fetch', max_uses: budgets.fetch }, + ], }), - query: { url: `${baseUrl}/huge` }, + searchProvider: makeSearchProvider(), }); - // The cap truncated the body instead of buffering all ~500 KB of it. - expect(result.length).toBeLessThan(5000); - }); + return { run, upstreamCalls: () => calls }; + }; + + /** + * The model never stops asking, but only ever asks for the kind whose budget + * is gone. Such a hop executes nothing and appends nothing, so the transcript + * never advances and the loop re-issues an identical request — each one a + * real billed round trip. Affordability is per hop, so that is a reason to + * stop even though the other kind still has allowance left. + */ + it('terminates when the model only ever asks for the spent kind', async () => { + const { run, upstreamCalls } = scriptedBoth( + [assistantToolCall('web_fetch', '{"url":"https://a.test"}', 'call_1')], + { fetch: 1, search: 8 }, + ); + + const outcome = await run(); + + // The first fetch ran; the second ask could afford nothing. + expect(outcome.executions).toHaveLength(1); + // One hop that ran, one that could not, and one closing call. + expect(upstreamCalls()).toBe(3); }); - describe('fetch tool definition', () => { - it('requires only a url', () => { - const tool = buildWebFetchToolDefinition(); + /** + * The point of a server tool: the model refines its query and searches + * again, all inside the one request the client sent. + */ + it('searches again when the model is not satisfied', async () => { + const { run, sentBodies } = scripted([ + ask('quantum computing', 'call_1'), + ask('IBM quantum 2026', 'call_2'), + { + choices: [ + { finish_reason: 'stop', message: { content: 'Here it is.' } }, + ], + }, + ]); + + const outcome = await run(); + + expect(sentBodies()).toHaveLength(3); + expect(outcome.executions).toHaveLength(2); + expect( + outcome.executions.map((execution) => + execution.type === 'web_search' ? execution.input.query : '', + ), + ).toEqual(['quantum computing', 'IBM quantum 2026']); + // Every search plus the answer is one turn from the client's side. + expect((await outcome.response.json()).choices[0].message.content).toBe( + 'Here it is.', + ); + }); - expect(tool.name).toBe('web_fetch'); - expect(tool.parameters).toMatchObject({ - properties: { - prompt: { type: 'string' }, - url: { type: 'string' }, - }, - required: ['url'], - type: 'object', - }); - }); + it('grows the transcript by one tool result per search', async () => { + const { run, sentBodies } = scripted([ + ask('one', 'call_1'), + ask('two', 'call_2'), + { choices: [{ finish_reason: 'stop', message: { content: 'done' } }] }, + ]); + + await run(); + + // user, assistant+tool, assistant+tool + expect(sentBodies()[1].messages).toHaveLength(3); + expect(sentBodies()[2].messages).toHaveLength(5); + expect( + (sentBodies()[2].messages as Array<{ tool_call_id?: string }>).map( + (message) => message.tool_call_id, + ), + // user, assistant(tool_calls), tool(call_1), assistant(tool_calls), tool(call_2) + ).toEqual([undefined, undefined, 'call_1', undefined, 'call_2']); }); - describe('tool name normalization', () => { - it('collapses the spellings upstream uses for the same tool', () => { - // The wire format is snake_case, but the model echoes the call back in - // whatever casing it prefers, so every spelling has to compare equal. - const spellings = [ - 'web_fetch', - 'WebFetch', - 'webFetch', - 'Web Fetch', - 'web-fetch', - ' WEB_FETCH ', - ]; - - for (const spelling of spellings) { - expect(normalizeToolName(spelling)).toBe('webfetch'); - } - - expect(normalizeToolName('web_fetch_20250910')).toBe( - normalizeToolName('WebFetch_20250910'), - ); - }); + /** + * `max_uses` bounds the turn, not the hop. Once it is spent the server tools + * are withdrawn for one last call so the model answers with what it has + * instead of asking for a search it will not get. + */ + it('stops at max_uses and asks once more without the server tool', async () => { + const { run, sentBodies } = scripted( + [ask('one', 'call_1'), ask('two', 'call_2'), ask('three', 'call_3')], + 2, + ); - it('keeps distinct tools distinct', () => { - expect(normalizeToolName('web_fetch')).not.toBe( - normalizeToolName('web_search'), - ); - expect(normalizeToolName('WebFetch')).not.toBe( - normalizeToolName('WebSearch'), - ); - }); + const outcome = await run(); + + expect(outcome.executions).toHaveLength(2); + // The closing call has no server tool left to call. + expect((sentBodies()[2] as { tools: unknown[] }).tools).toEqual([]); + // Nothing is left to choose from, so no tool_choice is sent at all: naming + // a tool that is not on offer is a contradiction some upstreams reject. + expect(sentBodies()[2].tool_choice).toBeUndefined(); }); - describe('server tool marker', () => { - it('ignores non-object values', () => { - expect(isMarkedServerTool(null)).toBe(false); - expect(isMarkedServerTool('nope')).toBe(false); - expect(stripServerToolMarker('plain')).toBe('plain'); + it('reports usage for every hop, not just the last', async () => { + const hops = [ + { ...ask('one', 'call_1'), usage: { total_tokens: 100 } }, + { ...ask('two', 'call_2'), usage: { total_tokens: 240 } }, + { + choices: [{ finish_reason: 'stop', message: { content: 'done' } }], + usage: { total_tokens: 80 }, + }, + ]; + + const outcome = await scripted(hops).run(); + + // Under-reporting the turn by every search that preceded the answer would + // bill the client for one hop out of three. + expect(outcome.usage).toEqual({ total_tokens: 420 }); + expect((await outcome.response.json()).usage).toEqual({ + total_tokens: 420, }); }); - describe('responses provenance', () => { - it('marks a translated server tool so it can be stripped later', () => { - const translated = translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) as Array>; + it('hands a failed hop back with the usage accrued so far', async () => { + const { run } = scripted([ + { ...ask('one', 'call_1'), usage: { total_tokens: 100 } }, + { error: { message: 'rate limited' } }, + ]); - // The declaration becomes a plain function for upstream, so the marker is - // the only surviving evidence that the client asked for a server tool. - expect(translated[0]?.function).toMatchObject({ name: 'web_fetch' }); - expect(isMarkedServerTool(translated[0])).toBe(true); - }); + const outcome = await run(); - it('does not mark a client-declared function of the same name', () => { - const translated = translateResponsesToolsToChat([ - { type: 'function', name: 'web_fetch', parameters: { type: 'object' } }, - ]) as Array>; + expect(outcome.response.status).toBe(200); + expect(outcome.usage).toEqual({ total_tokens: 100 }); + }); +}); - expect(isMarkedServerTool(translated[0])).toBe(false); - }); +/** + * These pin the behaviours that were fixed last and had no test at all: a + * client-declared `max_uses` per tool kind, nested usage blocks, a client + * pinning its *own* tool through `tool_choice`, and the id pairing between an + * assistant tool call and its result. + */ +let outcomeCalls = 0; +let closingCalls = 0; + +describe('server tool budgets and wire shape', () => { + const bothDeclarations = (searchUses: number, fetchUses: number) => + rewriteServerTools({ + declarations: { fetch: true, search: true }, + fetchProvider: makeFetchProvider(), + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: searchUses }, + { type: FETCH_TYPE, name: 'web_fetch', max_uses: fetchUses }, + ], + }); + + it('reads max_uses per declared tool, not one merged number', () => { + const rewrite = bothDeclarations(8, 2); + + // The fetch's budget used to cap the searches too. + expect(rewrite?.maxUses).toEqual({ web_fetch: 2, web_search: 8 }); + }); - it('passes through a translated server tool when fetch is passthrough', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', + it('clamps an absurd declared budget', () => { + const rewrite = rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: 5000 }], + }); - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); + // Every use is a sequential upstream round trip, so this has to be bounded. + expect(rewrite?.maxUses.web_search).toBeLessThanOrEqual(20); + }); - const translated = translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]); + it('leaves a tool_choice naming the client’s own WebSearch alone', async () => { + const sentBodies: ChatRequestBody[] = []; + let calls = 0; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: translated as unknown[], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); + await runServerToolTurn({ + body: { + ...body, + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: 8 }, + { name: 'WebSearch', input_schema: {}, type: 'function' }, + ], + tool_choice: { type: 'function', function: { name: 'WebSearch' } }, + } as never, + callUpstream: async (nextBody) => { + sentBodies.push(nextBody); + calls += 1; + + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"q"}')) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + // The same tool set: the client's `WebSearch` is what makes the respelled + // spelling unsafe to claim, so it has to be part of the rewrite too. + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [ + { type: SEARCH_TYPE, name: 'web_search', max_uses: 8 }, + { name: 'WebSearch', input_schema: {}, type: 'function' }, + ], + }), + searchProvider: makeSearchProvider(), + }); - expect(result?.body.tools).toEqual([ - expect.objectContaining({ - type: 'function', - function: expect.objectContaining({ name: 'web_fetch' }), - }), - ]); - expect(isMarkedServerTool(result?.body.tools?.[0])).toBe(false); + // Loosening this would stop the model calling the tool the client pinned. + expect(sentBodies[1]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'WebSearch' }, }); + }); - it('leaves a client-declared web_fetch function alone when a backend is set', async () => { - // The backend setting chooses who runs the *proxy's* tool, not whether - // the proxy may take over one the client declared. A client that ships - // its own `web_fetch` keeps resolving it — otherwise picking a backend - // would silently disable a capability the client asked for. - // - // The client's own parameters are the proof: the proxy's definition has - // `properties.url`, the client's here is `{ type: 'object' }`. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); + it('pairs each assistant tool call with its result, id included', async () => { + let calls = 0; + const sent: ChatRequestBody[] = []; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { - type: 'function', - function: { - name: 'web_fetch', - parameters: { type: 'object' }, - }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - // Nothing matched a server-tool declaration, so the loop declines to - // touch the request at all. - expect(result).toBeNull(); - }); - - it('keeps a client function of the same name when fetch cannot run', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { - type: 'function', - function: { - name: 'web_fetch', - parameters: { type: 'object' }, - }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); - - // Nothing matched a server-tool declaration, so the loop declines to - // touch the request at all — the client's own function is left exactly as - // sent rather than being rewritten or dropped. - expect(result).toBeNull(); - }); - - it('never forwards the marker upstream', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const translated = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - { type: 'keep', name: 'keep' }, - ]); - - let forwarded: unknown[] | undefined; - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: translated as unknown[], - } as ChatRequestBody, - callUpstream: async (loopBody) => { - forwarded = loopBody.tools; - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }, - }); - - expect((forwarded ?? []).some((tool) => isMarkedServerTool(tool))).toBe( - false, - ); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); - }); - - describe('settings', () => { - it('runs search by default but leaves web fetch to the client', async () => { - const config = await getActiveConfig(); - - expect(config.CODEBUDDY_WEB_SEARCH_BACKEND).toBe('searxng'); - expect(config.CODEBUDDY_WEB_FETCH_BACKEND).toBe('passthrough'); + await runServerToolTurn({ + body, + callUpstream: async (nextBody) => { + sent.push(nextBody); + calls += 1; - // Search stays on its historical default; fetch defaults to the client - // because a deployment has no basis for choosing a fetch backend itself. - await expect(isWebSearchEnabled()).resolves.toBe(true); - await expect(isWebFetchEnabled()).resolves.toBe(false); + return calls === 1 + ? makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + // No id: this is the case the fallback exists for. + tool_calls: [ + { + type: 'function', + function: { + arguments: '{"query":"q"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + }) + : makeJsonResponse({ choices: [] }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('is enabled by choosing a backend', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api' }); - - await expect(isWebFetchEnabled()).resolves.toBe(true); - - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough' }); + const assistant = sent[1]?.messages?.find( + (message: { role?: string }) => message.role === 'assistant', + ) as { tool_calls?: Array<{ id?: string }> } | undefined; + const tool = sent[1]?.messages?.find( + (message: { role?: string }) => message.role === 'tool', + ) as { tool_call_id?: string } | undefined; - await expect(isWebFetchEnabled()).resolves.toBe(false); - }); + // An assistant call with no id behind it cannot be paired by upstream. + expect(assistant?.tool_calls?.[0]?.id).toBeTruthy(); + expect(tool?.tool_call_id).toBe(assistant?.tool_calls?.[0]?.id); }); - describe('responses tool translation', () => { - it('emits web_fetch as a callable function', () => { - const result = translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) as Array<{ function: { name: string } }>; - - expect(result.map((entry) => entry.function.name)).toEqual(['web_fetch']); + it('sums nested usage blocks across hops', async () => { + const nested = (cached: number) => ({ + prompt_tokens: 10, + prompt_tokens_details: { cached_tokens: cached }, }); - it('emits both server tools when declared together', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const result = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) as Array<{ function: { name: string } }>; - - expect(result.map((entry) => entry.function.name)).toEqual([ - 'web_search', - 'web_fetch', - ]); + outcomeCalls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + const call = assistantToolCall('web_search', '{"query":"q"}'); + const hop = outcomeCalls++ === 0 ? call : { choices: [] }; - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + return makeJsonResponse({ ...hop, usage: nested(5) }); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('leaves an unrelated server tool alone', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - expect( - translateResponsesToolsToChat([{ type: 'file_search' }]), - ).toBeUndefined(); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + // The nested block used to be replaced by the last hop's, halving it. + expect(outcome.usage).toEqual({ + prompt_tokens: 20, + prompt_tokens_details: { cached_tokens: 10 }, }); }); +}); - describe('token scoping', () => { - it('uses the credential backing the request', async () => { - const seen: string[] = []; - - await withCodeBuddyToken( - async () => 'scoped-token', - async () => { - seen.push(String(await resolveCodeBuddyToken())); - }, - ); - - expect(seen).toEqual(['scoped-token']); - }); - - it('falls back to a saved credential outside a request scope', async () => { - await withCredential(); - - await expect(resolveCodeBuddyToken()).resolves.toBe('cred-token'); - }); - - it('reports no token when no credential exists', async () => { - await expect(resolveCodeBuddyToken()).resolves.toBeNull(); +/** + * What has to survive a hop that goes wrong. The searches have already run and + * been billed by then, and the proxy's own server-tool function must never + * reach the client: the client declared a *provider-executed* tool, so it has + * no handler for the name at all. + */ +describe('a hop that goes wrong', () => { + const searchOnly = (maxUses: number) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: makeSearchProvider(), + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: maxUses }], + }); + + /** + * A failure carrying both an `error` and a call that will never be answered. + * Handing the payload through as it arrived gave the client a `tool_use` for + * the proxy's internal `web_search` — a tool it never declared — and a finish + * reason that made it wait for a result that was never coming. + */ + it('hands a failed hop back with none of the proxy’s own calls', async () => { + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => + makeJsonResponse({ + ...assistantToolCall('web_search', '{"query":"q"}'), + error: { message: 'upstream exploded' }, + }), + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('ignores a credential that carries no bearer token', async () => { - await addCredential({ - access_token: '', - created_at: Math.floor(Date.now() / 1000), - user_id: 'empty', - }); - - await expect(resolveCodeBuddyToken()).resolves.toBeNull(); + await expect(outstandingCalls(outcome.response)).resolves.toEqual({ + finishReason: 'stop', + names: [], }); }); - describe('registry fallbacks', () => { - it('normalizes a blank search backend to the default', () => { - expect(normalizeSearchBackend('')).toBe('searxng'); - }); - - it('falls back to searxng for an unknown search backend', () => { - // No SEARXNG_URL here, so the fallback resolves to a backend that cannot - // be constructed — the point is that an unknown value is not an error. - expect( - resolveSearchProvider('bogus', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('falls back to no backend for an unknown fetch backend', () => { - // Unlike search, an unrecognised fetch value resolves to nothing: silently - // enabling a backend that fetches arbitrary model-supplied URLs would be - // the wrong default. - expect( - resolveFetchProvider('bogus', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('reports no provider when the search backend resolves to none', () => { - expect( - resolveSearchProvider('none', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('reuses one local fetch provider across calls', () => { - expect(resolveFetchProvider('local', async () => 'https://cb.test')).toBe( - resolveFetchProvider('local', async () => 'https://cb.test'), - ); - }); - - it('reports no configured backend when a search runs unscoped', async () => { - await expect( - runWebSearch({ backend: 'searxng', query: 'hello' }), - ).resolves.toContain('no local search backend is configured'); + it('does so on a failed closing call too, keeping the search that ran', async () => { + let calls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return calls === 1 + ? makeJsonResponse(assistantToolCall('web_search', '{"query":"q"}')) + : makeJsonResponse({ + ...assistantToolCall('web_search', '{"query":"q"}', 'call_2'), + error: { message: 'rate limited' }, + }); + }, + fetchProvider: null, + // One use, so the closing call comes straight after the first search. + rewrite: searchOnly(1), + searchProvider: makeSearchProvider(), }); - it('reports no enabled backend when a fetch runs unscoped', async () => { - await expect( - runWebFetch({ backend: 'none', query: { url: 'https://a.test' } }), - ).resolves.toContain('no web fetch backend is enabled'); + // Billed before the failure, and not lost with it. + expect(outcome.executions).toHaveLength(1); + await expect(outstandingCalls(outcome.response)).resolves.toEqual({ + finishReason: 'stop', + names: [], }); }); - describe('proxy integration', () => { - /** - * A `web_fetch` declaration as it arrives from a client: an Anthropic - * server-tool type run through the Responses translator, which is what - * marks it as provider-executed. Declaring the proxy's own definition - * directly would skip that step and arrive unmarked — indistinguishable - * from a tool the client owns. - */ - const translatedFetchTools = (): unknown[] => - translateResponsesToolsToChat([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ]) ?? []; - - const runOnce = async ({ - fetchImpl, - tools, - }: { - fetchImpl: (...args: unknown[]) => Promise; - tools: unknown[]; - }): Promise => { - await withCredential(); - stubFetch(fetchImpl); - - const context = createProxyContextFromCredential({ - data: { bearer_token: 'cred-token', user_id: 'tester' }, - filePath: '/tmp/cred.json', - filename: 'cred.json', - }); - - const response = await proxyChatCompletions( - new NextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ content: 'hi', role: 'user' }], - tools, - } as ChatRequestBody, - context, - ); - - return response; + /** + * Every other hop reads its body through a guard that turns an unparseable + * body into an error; the closing call used to read it directly and threw, + * discarding every search the turn had already run and paid for. + */ + it('keeps the searches when the closing call answers with a body that will not parse', async () => { + let calls = 0; + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; + + return calls < 3 + ? makeJsonResponse( + assistantToolCall( + 'web_search', + `{"query":"q${calls}"}`, + `call_${calls}`, + ), + ) + : makeHtmlResponse(); + }, + fetchProvider: null, + rewrite: searchOnly(2), + searchProvider: makeSearchProvider(), + }); + + // Two searches and the closing call that failed to speak JSON: resolving at + // all is the point, and the turn must not have asked again. + expect(calls).toBe(3); + expect(outcome.executions).toHaveLength(2); + // Still recoverable from the response, which is where the Responses path + // reads them from. + expect(getServerToolExecutions(outcome.response)).toHaveLength(2); + const payload = (await outcome.response.json()) as { + error?: { message?: string }; }; - it('executes a web_fetch call through the local backend', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - }); - - let call = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('/v2/chat/completions')) { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Fetched.' } }, - ], - }); - } + // Surfaced as a failure rather than swallowed. + expect(payload.error?.message).toBeTruthy(); + }); +}); - return new Response('

Page body

', { - headers: { 'Content-Type': 'text/html' }, - status: 200, - }); - }, - tools: translatedFetchTools(), - }); +/** + * Every hop is an upstream round trip and every search a backend call, so a + * client that hangs up mid-turn should not be charged for the rest of the + * budget it declared. + */ +describe('a client that hangs up', () => { + const answerHop = (text = 'done'): Record => ({ + choices: [ + { finish_reason: 'stop', message: { content: text, role: 'assistant' } }, + ], + }); - const payload = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - expect(payload.choices[0]?.message.content).toBe('Fetched.'); - expect(call).toBe(2); - }); + const rewriteWithBudget = ( + searchProvider: WebSearchProvider, + maxUses: number, + ) => + rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider, + tools: [{ type: SEARCH_TYPE, name: 'web_search', max_uses: maxUses }], + })!; + + /** + * Aborts once `abortAfter` upstream calls have answered, so the hop that + * produced the last one completes in full and the next checkpoint is where + * the turn stops spending. + */ + const hangingUpTurn = ({ + abortAfter, + hops, + maxUses = 5, + }: { + abortAfter: number; + hops: Array>; + maxUses?: number; + }) => { + const controller = new AbortController(); + let calls = 0; + let searches = 0; + + const searchProvider: WebSearchProvider = { + id: 'hangup-search', + search: async () => { + searches += 1; + + return { content: 'findings', results: [] }; + }, + }; - it('executes a fetch the model echoes back as WebFetch', async () => { - // Regression guard: upstream returns the call as `WebFetch`, not - // `web_fetch`. An exact name match missed it, so the call was neither - // executed nor taken over — it was handed straight back to the client - // unresolved, and the fetch silently never happened. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); + const run = (): Promise => + runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; - let call = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('/v2/chat/completions')) { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'WebFetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Fetched.' } }, - ], - }); + if (calls > 25) { + throw new Error( + `the turn did not terminate: ${calls} upstream calls`, + ); } - return new Response('

Page body

', { - headers: { 'Content-Type': 'text/html' }, - status: 200, - }); - }, - tools: translatedFetchTools(), - }); - - const payload = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - - // A second upstream call means the tool ran and its result was folded - // back in. Before the fix the loop broke on the first response and - // returned the unresolved call, so this was 1. - expect(payload.choices[0]?.message.content).toBe('Fetched.'); - expect(call).toBe(2); - }); - - it('executes a search the model echoes back as WebSearch', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - let call = 0; - let searched = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('searx.test')) { - searched += 1; - - return makeJsonResponse({ - results: [ - { - content: 'A snippet', - title: 'Docs', - url: 'https://docs.test', - }, - ], - }); - } + const response = makeJsonResponse( + hops[Math.min(calls - 1, hops.length - 1)], + ); - if (url.includes('/v2/chat/completions')) { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"latest news"}', - name: 'WebSearch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Searched.' }, - }, - ], - }); + if (calls === abortAfter) { + controller.abort(); } - return makeJsonResponse({}); + return response; }, - tools: [{ type: 'function', function: buildWebSearchToolDefinition() }], + fetchProvider: null, + rewrite: rewriteWithBudget(searchProvider, maxUses), + searchProvider, + signal: controller.signal, }); - const payload = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - - expect(payload.choices[0]?.message.content).toBe('Searched.'); - expect(searched).toBe(1); - expect(call).toBe(2); - - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - }); + return { calls: () => calls, run, searches: () => searches }; + }; - it('passes through a fetch server-tool declaration', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - - const callUpstream = vi.fn(async (_loopBody: ChatRequestBody) => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - ); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - { type: 'function', function: { name: 'keep_me' } }, - ], - } as ChatRequestBody, - callUpstream: callUpstream as never, - }); - - // Passthrough never starts the local loop; the ordinary proxy path sends - // both tools upstream after the internal marker is removed. - expect(callUpstream).not.toHaveBeenCalled(); - expect(result?.response).toBeNull(); - expect(result?.body.tools).toEqual([ - { type: 'web_fetch_20250910', name: 'web_fetch' }, - { type: 'function', function: { name: 'keep_me' } }, - ]); - }); - - it('drops a client-declared function it would otherwise keep once disabled', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); + it('spends nothing when the caller is already gone', async () => { + const controller = new AbortController(); + controller.abort(); + let calls = 0; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'function', function: { name: 'keep_me' } }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }), - }); + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; - // No server tool was declared, so nothing is rewritten. - expect(result).toBeNull(); + return makeJsonResponse(answerHop()); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), + signal: controller.signal, }); - it('withdraws server tools when the model keeps calling them', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - stubFetch(async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: `call_${url.length}`, - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - } as ChatRequestBody, - callUpstream: async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_loop', - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }), - }); - - // The budget ran out, so the final call goes out with the server tool - // withdrawn — otherwise the model would search forever. - const payload = await readPayload(result); - expect(payload.choices).toBeDefined(); + expect(calls).toBe(0); + expect(outcome.executions).toHaveLength(0); + // Non-ok, so every caller stops rendering rather than emitting an answer + // nobody is listening for. + expect(outcome.response.ok).toBe(false); + }); - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + it('stops asking upstream after the caller leaves', async () => { + const { calls, run, searches } = hangingUpTurn({ + abortAfter: 1, + hops: [assistantToolCall('web_search', '{"query":"q"}'), answerHop()], }); - it('runs server search while still handing a client web_fetch back', async () => { - // The case the declaration-level guard alone does not cover: a - // server-declared search makes the loop run, so the proxy's fetch - // backend is active and the model calls both tools in one turn. Call - // classification has to consult ownership, not just the name — - // otherwise the client's `web_fetch` is executed here even though its - // declaration was left untouched. - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - let upstreamCalls = 0; - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - ...(translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - ]) ?? []), - { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => { - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - role: 'assistant', - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - { - id: 'c2', - function: { - arguments: '{"query":"q"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'ANSWER' } }, - ], - }); - }, - }); + await run(); - // The loop stopped after one turn: the search ran, and the fetch was - // returned to the client rather than answered by another round. - expect(upstreamCalls).toBe(1); - // Only the client's fetch is still outstanding — the search was executed - // locally, so it is gone from the call list. - const payload = await readPayload(result); - const toolCalls = ( - payload.choices as Array<{ - message?: { - tool_calls?: Array<{ function?: { name?: string } }>; - }; - }> - )?.[0]?.message?.tool_calls; - expect(toolCalls?.map((call) => call.function?.name)).toEqual([ - 'web_fetch', - ]); + // The hop was already in flight when the caller left, so it cannot be + // recalled any cheaper than letting it finish — but it runs nothing, and + // nothing follows it. + expect(calls()).toBe(1); + expect(searches()).toBe(0); + }); - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + it('does not spend the closing call once the caller leaves', async () => { + // One search is the whole budget, so hop 1 leaves the turn ready to make + // its closing call. That call is the next thing to skip. + const { calls, run } = hangingUpTurn({ + abortAfter: 1, + hops: [assistantToolCall('web_search', '{"query":"q"}'), answerHop()], + maxUses: 1, }); - it('hands a client-declared web_fetch call back even with a backend set', async () => { - // End-to-end companion to the declaration-level guard: the backend is - // executable, so the only thing keeping this the client's call is the - // declaration being client-owned. The model calls the tool, the proxy - // must not answer it — the unresolved call goes back to the client. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - - let upstreamCalls = 0; - let pageFetches = 0; - const response = await runOnce({ - fetchImpl: async (...args: unknown[]) => { - const url = String(args[0]); - - if (url.includes('a.test/page')) { - pageFetches += 1; - - return new Response('

Page body

', { - headers: { 'Content-Type': 'text/html' }, - status: 200, - }); - } + await run(); - upstreamCalls += 1; - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }); - }, - tools: [ - { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }, - ], - }); + expect(calls()).toBe(1); + }); - const payload = (await response.json()) as { - choices: Array<{ message: { tool_calls?: unknown[] } }>; - }; + it('keeps the searches it already ran', async () => { + const { run, searches } = hangingUpTurn({ + abortAfter: 2, + hops: [ + assistantToolCall('web_search', '{"query":"q"}'), + assistantToolCall('web_search', '{"query":"q2"}'), + answerHop(), + ], + }); + + const outcome = await run(); + + // Hop 1's search ran before the caller left. Hop 2's was still owed when + // they went, so it is not executed and billed on their behalf. + expect(searches()).toBe(1); + expect(outcome.executions).toHaveLength(1); + // Still recoverable from the response, which is where the Responses path + // reads them from: it really ran and was really billed. + expect(getServerToolExecutions(outcome.response)).toHaveLength(1); + }); - // The proxy never fetched the page and never re-asked upstream; the call - // comes back for the client to resolve. - expect(pageFetches).toBe(0); - expect(upstreamCalls).toBe(1); - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - }); + /** + * Consistent with every other way out of the turn: the internal function is + * never handed to the client, not even when the turn is cut short. A + * `tool_use` for a `web_search` the client never declared is a call it has + * no handler for. + */ + it('hands back no server-tool call the client would have to resolve', async () => { + const { run } = hangingUpTurn({ + abortAfter: 1, + hops: [assistantToolCall('web_search', '{"query":"q"}'), answerHop()], + }); + + const outcome = await run(); + const payload = (await outcome.response.json()) as { + choices?: Array<{ message?: { tool_calls?: unknown[] } }>; + }; - it('keeps a client fetch out of the loop when a server search runs', async () => { - // The declaration-level guard alone is not enough: a server-declared - // search starts the loop, and once it is running the call classifier - // used to match on name alone — so the client's `web_fetch` was executed - // alongside the search it had nothing to do with. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy2api', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); + expect(payload.choices?.[0]?.message?.tool_calls ?? []).toEqual([]); + }); - let upstreamCalls = 0; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - // A server tool the proxy owns... - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - // ...and a client-owned tool the proxy must not touch. - { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }, - ], - } as ChatRequestBody, - callUpstream: async () => { - upstreamCalls += 1; - - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"url":"https://a.test/page"}', - name: 'web_fetch', - }, - }, - { - id: 'c2', - function: { - arguments: '{"query":"q"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Done.' } }, - ], - }); - }, - }); + it('runs to completion when no signal is given', async () => { + let calls = 0; - // Only the search ran. The client's fetch is left for the client. - expect(result?.executions.map((execution) => execution.type)).toEqual([ - 'web_search', - ]); + const outcome = await runServerToolTurn({ + body, + callUpstream: async () => { + calls += 1; - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); + return makeJsonResponse( + calls === 1 + ? assistantToolCall('web_search', '{"query":"q"}') + : answerHop('It shipped yesterday.'), + ); + }, + fetchProvider: null, + rewrite: makeRewrite(makeSearchProvider()), + searchProvider: makeSearchProvider(), }); - it('leaves a client-declared web_fetch function alone when disabled', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - - const declared = { - type: 'function', - function: { name: 'web_fetch', parameters: { type: 'object' } }, - }; - let upstreamTools: unknown[] | undefined; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [declared], - } as ChatRequestBody, - callUpstream: async (loopBody) => { - upstreamTools = loopBody.tools; - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'No tools.' } }, - ], - }); - }, - }); - - // The client resolves its own tool, so the proxy must not delete it even - // though local fetch is off — and with nothing to execute, the loop - // declines to run at all. - expect(result).toBeNull(); - expect(upstreamTools).toBeUndefined(); - }); + expect(calls).toBe(2); + expect(outcome.response.ok).toBe(true); }); }); diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index d4ec55d..46d161f 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -5334,8 +5334,11 @@ describe('server units', () => { ]); expect(result).toHaveLength(7); + // A provider-executed declaration keeps its declared type: it is the only + // thing that tells it apart from the client's own function of the same + // name, and upstream never sees it because the turn rewrites it first. expect(result?.[0]).toMatchObject({ - type: 'function', + type: 'web_search_preview', function: { name: 'web_search', }, @@ -5358,7 +5361,7 @@ describe('server units', () => { }, }); expect(result?.[3]).toMatchObject({ - type: 'function', + type: 'web_search_preview', function: { name: 'web_search', }, @@ -5506,8 +5509,6 @@ describe('server units', () => { ]), ).toEqual([ { - // Marked as server-declared so the proxy knows it executes the call. - 'x-codebuddy2api-server-tool': true, type: 'function', function: expect.objectContaining({ name: 'image_generation', diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index 02341f4..d78bccd 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -3,11 +3,7 @@ import path from 'node:path'; import { NextRequest } from 'next/server'; -import { - getSettingLabels, - isWebSearchEnabled, - updateSettings, -} from '@/lib/server/domain/config'; +import { updateSettings } from '@/lib/server/domain/config'; import { getWebSearchProvider, isLocalWebSearchConfigured, @@ -21,22 +17,18 @@ import { } from '@/lib/server/search/providers/searxng'; import { buildWebSearchToolDefinition } from '@/lib/server/search/tool'; import { - createProxyContextFromCredential, - proxyChatCompletions, -} from '@/lib/server/proxy/codebuddy'; + rewriteServerTools, + runServerToolTurn, +} from '@/lib/server/proxy/server-tools'; +import { proxyChatCompletions } from '@/lib/server/proxy/codebuddy'; import { addCredential, resetCredentialRuntimeState, } from '@/lib/server/domain/credentials'; -import { resetUsageStats } from '@/lib/server/domain/stats'; -import type { ChatRequestBody } from '@/lib/server/proxy/codebuddy'; import { translateResponsesToolsToChat } from '@/lib/server/proxy/responses'; import { handleResponsesRequest } from '@/lib/server/proxy/responses'; import { handleMessagesRequest } from '@/lib/server/proxy/anthropic'; -import { - executeWebSearchLoop, - synthesizeChatCompletionStream, -} from '@/lib/server/proxy/web-search-loop'; +import { mapAnthropicToolsToChat } from '@/lib/server/proxy/anthropic/request'; const SEARXNG_ENV_NAMES = [ 'SEARXNG_URL', @@ -70,39 +62,20 @@ const makeSseResponse = (...chunks: Record[]): Response => { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, ); -type LoopCall = (body: ChatRequestBody) => Promise; - -/** - * Reads the loop's buffered payload, asserting the loop ran. - * - * `executeWebSearchLoop` legitimately returns a null response when no backend - * can execute the declared tools, so every assertion on the payload has to rule - * that out first rather than silently reading through a nullable. - */ -const readPayload = async ( - result: { response: Response | null } | null, -): Promise> => { - if (!result?.response) { - throw new Error('Expected the server-tool loop to produce a response'); - } - - return (await result.response.json()) as Record; -}; - -const readSseEvents = async (response: Response): Promise => { - const text = await response.text(); - - return text - .split('\n\n') - .map((frame) => - frame - .split('\n') - .filter((line) => line.startsWith('data: ')) - .map((line) => line.slice(6)) - .join(''), - ) - .filter((payload) => payload.length > 0); -}; +/** Upstream is always asked to stream, so the follow-up answer arrives as SSE. */ +const makeSseAnswer = (content: string): Response => + makeSseResponse( + { + choices: [{ delta: { content, role: 'assistant' }, index: 0 }], + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + }, + { + choices: [{ delta: {}, finish_reason: 'stop', index: 0 }], + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + }, + ); describe('server local web search', () => { beforeEach(() => { @@ -486,7 +459,7 @@ describe('server local web search', () => { await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); let call = 0; - const callUpstream = vi.fn(async () => { + const callUpstream = vi.fn(async () => { call += 1; return call === 1 @@ -513,12 +486,20 @@ describe('server local web search', () => { }); }); - await executeWebSearchLoop({ + await runServerToolTurn({ body: { messages: [{ content: 'hi', role: 'user' }], tools: [{ type: 'web_search_preview' }], }, callUpstream, + fetchProvider: null, + rewrite: rewriteServerTools({ + declarations: { fetch: false, search: true }, + fetchProvider: null, + searchProvider: resolveSearchProvider('searxng'), + tools: [{ type: 'web_search_preview' }], + })!, + searchProvider: resolveSearchProvider('searxng'), }); if (!fetchMock.mock.calls.length) { @@ -555,7 +536,7 @@ describe('server local web search', () => { }); it('skips the search for a non-object argument payload', async () => { - // No query can be recovered, so no request is made and the loop reports + // No query can be recovered, so no request is made and the turn reports // that back to the model as tool result text. await expect(runOnce('42')).resolves.toBeNull(); }); @@ -567,5790 +548,1676 @@ describe('server local web search', () => { await expect(runOnce('{"text":"alias two"}')).resolves.toBe('alias two'); }); }); +}); - describe('search loop', () => { - const enableSearch = async (): Promise => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - }; +// --------------------------------------------------------------------------- +// Route-level behaviour +// +// The corrected flow, end to end. A client declares `WebSearch` as an ordinary +// function and resolves it itself; the proxy must hand the model's call back as +// a `tool_use` block and run nothing. Only a request whose tools carry a +// provider-executed *type* — the sub-request Claude Code sends once it has a +// `WebSearch` result to fill in — runs a search here. +// --------------------------------------------------------------------------- - it('skips requests that declare no web search tool', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => makeJsonResponse({})); +describe('responses tool translation', () => { + it('keeps a provider-executed declaration’s type on the chat tool', () => { + const translated = translateResponsesToolsToChat([ + { type: 'web_search_preview' }, + ]) as Array<{ type: string; function: { name: string } }>; - await expect( - executeWebSearchLoop({ - body: { messages: [{ content: 'hi', role: 'user' }], tools: [] }, - callUpstream, - }), - ).resolves.toBeNull(); - expect(callUpstream).not.toHaveBeenCalled(); - }); + expect(translated).toHaveLength(1); + // Downstream classification reads the type, so it has to survive. + expect(translated[0].type).toBe('web_search_preview'); + expect(translated[0].function.name).toBe('web_search'); + }); - it('does not run the loop when the setting is disabled', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - // Both off: the loop runs if *either* tool can be executed, so leaving - // fetch enabled would make it call upstream regardless of search. - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough', - }); - const callUpstream = vi.fn(async () => makeJsonResponse({})); + it('translates a client function as an ordinary function', () => { + const translated = translateResponsesToolsToChat([ + { type: 'function', name: 'Read', parameters: {} }, + ]) as Array<{ type: string; function: { name: string } }>; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - callUpstream, - }); + expect(translated[0].type).toBe('function'); + expect(translated[0].function.name).toBe('Read'); + }); +}); - // No upstream call: nothing can execute, so there is nothing to loop for. - expect(callUpstream).not.toHaveBeenCalled(); - expect(result?.response).toBeNull(); - expect(result?.body.tools).toEqual([ - { type: 'web_search_20260209', name: 'web_search' }, - ]); +/** + * §9. The declared type is the only thing that carries the server-tool / + * client-tool distinction through translation — `normalizeToolName` makes + * `WebSearch` and `web_search` the same string, so the name cannot. The + * Responses translator is covered above; this is the Anthropic one, whose + * declaration is the shape Claude Code's side request actually sends. + */ +describe('anthropic tool translation', () => { + it('keeps a declared server tool’s type and its max_uses', () => { + // `max_uses` is on the declaration Claude Code sends but not on + // `AnthropicTool`, so it enters through a spread — the same way the side + // request builds its own declaration. The translator spreads every field + // the client declared through, so it belongs in what is asserted here. + const translated = mapAnthropicToolsToChat([ + { + input_schema: {}, + name: 'web_search', + type: 'web_search_20250305', + ...{ max_uses: 8 }, + }, + ]) as Array>; + + expect(translated).toHaveLength(1); + // Downstream classification reads the type, so it has to survive — and the + // budget rides on the declaration the client sent, not on a default. + expect(translated[0]).toMatchObject({ + max_uses: 8, + type: 'web_search_20250305', }); + // Reshaped into a function upstream can call, but still the same tool. + expect(translated[0]).toMatchObject({ function: { name: 'web_search' } }); + }); - it('drops an unconfigured non-passthrough server declaration', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - const callUpstream = vi.fn(async () => makeJsonResponse({})); + it('translates Claude Code’s bare WebSearch as an ordinary function', () => { + const translated = mapAnthropicToolsToChat([ + { name: 'WebSearch', input_schema: {} }, + ]) as Array<{ type: string; function: { name: string } }>; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - callUpstream, - }); + // No `type` on the way in means the client resolves the call itself. + expect(translated[0].type).toBe('function'); + expect(translated[0].function.name).toBe('WebSearch'); + }); +}); + +describe('server tool routing', () => { + const tempRootDir = path.join(process.cwd(), '.tmp-servertool-route-root'); + const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); + + const cleanupDir = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); + }; - expect(callUpstream).not.toHaveBeenCalled(); - expect(result?.body.tools).toEqual([]); + const makeRequest = (url: string): NextRequest => + new NextRequest(url, { + method: 'POST', + headers: { authorization: 'Bearer servertool-token' }, }); - it.each([ - [ - 'anthropic server tool', - { type: 'web_search_20260209', name: 'web_search' }, - ], - [ - 'anthropic legacy tool', - { type: 'web_search_20250305', name: 'web_search' }, - ], - ['responses preview tool', { type: 'web_search_preview' }], - ])('replaces the %s with a callable function', async (_label, tool) => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), - ); + const enableSearch = async (): Promise => { + process.env.SEARXNG_URL = 'https://searx.test'; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + }; - const result = await executeWebSearchLoop({ - body: { messages: [{ content: 'hi', role: 'user' }], tools: [tool] }, - callUpstream, - }); + /** Upstream answers the first call with a tool call and the rest with text. */ + const mockUpstream = ({ + toolName = 'web_search', + answer = 'It shipped yesterday.', + arguments: args = '{"query":"latest release"}', + } = {}): { upstreamCalls: () => number } => { + let calls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); - expect(result).not.toBeNull(); - const upstreamBody = callUpstream.mock.calls[0]?.[0] as ChatRequestBody; - const tools = upstreamBody.tools as Array<{ - function: { name: string }; - }>; - expect(tools.map((entry) => entry.function.name)).toEqual(['web_search']); - }); + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } - it('runs the query and feeds results back to the model', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + calls += 1; + + return calls === 1 + ? (makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { arguments: args, name: toolName }, + }, + ], + }, + }, ], - }), - ) as unknown as typeof fetch, - ); + }) as unknown as Response) + : makeSseAnswer(answer); + }); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; + return { upstreamCalls: () => calls }; + }; - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - role: 'assistant', - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - arguments: '{"query":"current weather"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'It is sunny.' } }, - ], - }); - }); + const readEvents = async ( + response: Response, + ): Promise> => { + const text = await response.text(); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'weather?', role: 'user' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - callUpstream, - }); + return text + .split('\n\n') + .map((frame) => { + const lines = frame.split('\n'); + const event = lines + .find((line) => line.startsWith('event: ')) + ?.slice(7) + .trim(); + const data = lines + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join(''); - expect(callUpstream).toHaveBeenCalledTimes(2); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string } }>; - }; - expect(payload.choices[0]?.message.content).toBe('It is sunny.'); - - const secondCallBody = callUpstream.mock.calls[1]?.[0] as ChatRequestBody; - const secondMessages = (secondCallBody.messages ?? []) as Array< - Record - >; - const toolMessage = secondMessages - .filter((message) => message.role === 'tool') - .at(-1); - expect(String(toolMessage?.content)).toContain('https://docs.test'); - expect(toolMessage?.tool_call_id).toBe('call_1'); + return { data, event: event ?? '' }; + }) + .filter((frame) => frame.data.length > 0); + }; + + beforeEach(async () => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + resetWebSearchProviders(); + resetCredentialRuntimeState(); + cleanupDir(); + fs.mkdirSync(tempDataDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + await addCredential({ + bearer_token: 'servertool-token', + responses_passthrough: false, + user_id: 'servertool@example.com', }); + }); + + afterEach(() => { + for (const name of SEARXNG_ENV_NAMES) { + delete process.env[name]; + } + resetWebSearchProviders(); + cleanupDir(); + vi.restoreAllMocks(); + }); - it('accepts alternate query argument shapes', async () => { + describe('/v1/messages', () => { + /** + * The regression, end to end. Claude Code declares `WebSearch` as an + * ordinary function and resolves it itself, so the proxy has to hand the + * model's call straight back. Executing it here instead is what left Claude + * Code with an answer invented from memory and no search at all. + */ + it('hands Claude Code’s own WebSearch call back as a tool_use block', async () => { await enableSearch(); - const fetchMock = vi.fn(async () => makeJsonResponse({ results: [] })); - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + const { upstreamCalls } = mockUpstream({ toolName: 'WebSearch' }); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'Search for the release date' }], + tools: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_q', - function: { - arguments: '{"q":"alternate query"}', - name: 'web_search', - }, - }, - ], - }, + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, }, ], - }), + }, ); - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); + const payload = (await response.json()) as { + content: Array>; + stop_reason: string; + }; - const [url] = fetchMock.mock.calls[0] as unknown as [string]; - expect(url).toContain('q=alternate+query'); + expect(payload.content).toEqual([ + expect.objectContaining({ + id: 'call_1', + name: 'WebSearch', + type: 'tool_use', + }), + ]); + expect(payload.stop_reason).toBe('tool_use'); + // Nothing was executed, so upstream was asked exactly once. + expect(upstreamCalls()).toBe(1); }); - it('stops after the iteration cap when the model keeps searching', async () => { + it('runs the search for a declared server tool and reports it structurally', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - - // The model always asks to search; only the final call, which has the - // search tool withdrawn, produces an answer. - const callUpstream = vi.fn(async (body) => { - const hasSearchTool = ( - (body.tools ?? []) as Array<{ - function?: { name?: string }; - }> - ).some((tool) => tool.function?.name === 'web_search'); + const { upstreamCalls } = mockUpstream(); - return hasSearchTool - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_loop', - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Enough.' } }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'when did it ship?' }], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, + }, + ], }, - callUpstream, - }); - - // 5 search iterations plus one final call with the search tool removed. - expect(callUpstream).toHaveBeenCalledTimes(6); + ); - const finalBody = callUpstream.mock.calls[5]?.[0] as ChatRequestBody; - expect(finalBody.tools).toEqual([]); + const payload = (await response.json()) as { + content: Array>; + stop_reason: string; + }; - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content?: string } }>; + // Anthropic's own order: the call, its result, then the answer. + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.content[0]).toMatchObject({ + input: { query: 'latest release' }, + name: 'web_search', + }); + const result = payload.content[1] as { + content: Array<{ url: string }>; + tool_use_id: string; }; - expect(payload.choices[0]?.message.content).toBe('Enough.'); - expect(result?.response?.ok).toBe(true); + expect(result.content[0].url).toBe('https://docs.test'); + expect(result.tool_use_id).toBe(payload.content[0].id); + expect(payload.content[2]).toEqual({ + text: 'It shipped yesterday.', + type: 'text', + }); + expect(payload.stop_reason).toBe('end_turn'); + expect(upstreamCalls()).toBe(2); }); - it('preserves unrelated tools and passes through non-search tool calls', async () => { + it('streams the server tool blocks ahead of the answer', async () => { await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ + mockUpstream(); + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'when did it ship?' }], + stream: true, + tools: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_other', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, }, ], - }), + }, ); - const fetchMock = vi.fn(async () => makeJsonResponse({ results: [] })); - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { type: 'web_search_preview' }, - { type: 'function', function: { name: 'read_file' } }, - ], - }, - callUpstream, - }); + expect(response.headers.get('content-type')).toContain( + 'text/event-stream', + ); - expect(fetchMock).not.toHaveBeenCalled(); - expect(callUpstream).toHaveBeenCalledTimes(1); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { tool_calls?: unknown[] } }>; - }; - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - }); - - it('folds search findings into text when a turn mixes search and client calls', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { - content: 'Docs snippet', - title: 'Docs', - url: 'https://docs.test', - }, - ], - }), - ) as unknown as typeof fetch, + const events = await readEvents(response); + const starts = events.filter( + (event) => event.event === 'content_block_start', ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking now.', - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"release date"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{"path":"a"}', name: 'read_file' }, - }, - ], - }, - }, - ], - usage: { total_tokens: 12 }, - }), + const types = starts.map( + (event) => + (JSON.parse(event.data) as { content_block: { type: string } }) + .content_block.type, ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [ - { type: 'web_search_preview' }, - { type: 'function', function: { name: 'read_file' } }, - ], - }, - callUpstream, - }); - - // The loop stops after one iteration instead of continuing with a - // transcript that has no result for read_file. - expect(callUpstream).toHaveBeenCalledTimes(1); - - const payload = (await readPayload(result)) as { - choices: Array<{ - finish_reason: string | null; - message: { - content: string | null; - tool_calls?: Array<{ function?: { name?: string }; id?: string }>; - }; - }>; - usage?: { total_tokens?: number }; - }; - const choice = payload.choices[0]; - - expect(choice?.finish_reason).toBe('tool_calls'); - expect(choice?.message.content).toContain('Checking now.'); - expect(choice?.message.content).toContain('https://docs.test'); - // Only the client-owned call is handed back. - expect(choice?.message.tool_calls).toHaveLength(1); - expect(choice?.message.tool_calls?.[0]?.id).toBe('call_read'); - expect(payload.usage?.total_tokens).toBe(12); + expect(types).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(events[0].event).toBe('message_start'); + // The answer the results produced still reaches the client. + expect(JSON.stringify(events)).toContain('It shipped yesterday.'); }); - it('keeps a mixed turn that carries usage alongside findings', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ + it('leaves a server tool with no backend for the client to resolve', async () => { + // Backend is passthrough, so nothing runs here. + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); + const { upstreamCalls } = mockUpstream(); + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'when did it ship?' }], + tools: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, }, ], - usage: { total_tokens: 3 }, - }), - ); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], }, - callUpstream, - }); + ); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { tool_calls?: unknown[] } }>; - usage?: { total_tokens?: number }; + const payload = (await response.json()) as { + content: Array>; }; - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - expect(payload.usage?.total_tokens).toBe(3); + expect(payload.content.map((block) => block.type)).toEqual(['tool_use']); + expect(upstreamCalls()).toBe(1); }); - it('omits usage from a mixed turn when upstream reports none', async () => { + it('reports an upstream failure as an Anthropic error', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, + vi.spyOn(globalThis, 'fetch').mockImplementation( + async () => + new Response(JSON.stringify({ error: { message: 'nope' } }), { + headers: { 'Content-Type': 'application/json' }, + status: 429, + }) as unknown as Response, ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 256, + messages: [{ role: 'user', content: 'hi' }], + tools: [ { - finish_reason: 'tool_calls', - message: { - content: 'partial', - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { - id: 'call_read', - function: { arguments: '{}', name: 'read_file' }, - }, - ], - }, + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, }, ], - }), + }, ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, + expect(response.status).toBe(429); + expect((await response.json()) as { type: string }).toMatchObject({ + type: 'error', }); + }); + }); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string | null } }>; - usage?: unknown; - }; + /** + * These go through `handleMessagesRequest`, not straight into + * `rewriteServerTools`: the spec's phase-2 shape only ever arrives as an + * Anthropic request, and translation is where `max_uses` and `tool_choice` + * used to be dropped — which no unit test could see. + */ + describe('phase 2: the side request, end to end', () => { + /** Answers each hop from `hops`, falling through on the last. */ + const routed = ( + hops: Array>, + request: Record, + ) => { + const sent: Array> = []; + let calls = 0; - expect(payload.choices[0]?.message.content).toContain('partial'); - expect(payload.usage).toBeUndefined(); - }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + const url = String(_input); - it('leaves non-primary choices untouched in a mixed turn', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ + if (url.includes('searx.test')) { + return makeJsonResponse({ results: [ - { content: 'snippet', title: 'T', url: 'https://t.test' }, + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, ], - }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { id: 'call_read', function: { name: 'read_file' } }, - ], - }, - }, - { finish_reason: 'stop', message: { content: 'second choice' } }, - ], - }), - ); + }) as unknown as Response; + } - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, + const body = JSON.parse(String(init?.body)) as Record; + sent.push(body); + const hop = hops[Math.min(calls, hops.length - 1)]; + calls += 1; + + return makeJsonResponse(hop) as unknown as Response; }); - const payload = (await readPayload(result)) as { - choices: Array<{ - finish_reason: string | null; - message: { content: string | null; tool_calls?: unknown[] }; - }>; + return { + calls: () => calls, + run: () => + handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + request, + ), + sent, }; + }; - // The second choice passes through unchanged. - expect(payload.choices[1]?.message.content).toBe('second choice'); - // The first is rewritten to carry the findings. - expect(payload.choices[0]?.message.content).toContain('https://t.test'); - expect(payload.choices[0]?.message.tool_calls).toHaveLength(1); - }); - - it('truncates long snippets and titles in the rendered findings', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ + /** + * The queries the turn actually put to the search provider, one per call. + * + * Read off the fetch spy rather than a counter on the hop mock: `routed` + * answers the provider before it counts the call, so a search is invisible + * to `calls()` — which is the point, but it means only this can prove what + * was searched for. + */ + const providerQueries = (): Array => + vi + .mocked(globalThis.fetch) + .mock.calls.map(([input]) => String(input)) + .filter((url) => url.includes('searx.test')) + .map((url) => new URL(url).searchParams.get('q')); + + const searchCall = (query: string, id: string) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ { - content: 'word '.repeat(400), - title: 'T'.repeat(400), - url: 'https://long.test', + id, + type: 'function', + function: { + arguments: `{"query":"${query}"}`, + name: 'web_search', + }, }, ], - }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - { id: 'call_read', function: { name: 'read_file' } }, - ], - }, - }, - ], - }), - ); + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], + const answer = (text: string, reasoning?: string) => ({ + choices: [ + { + finish_reason: 'stop', + message: { + content: text, + ...(reasoning ? { reasoning_content: reasoning } : {}), + role: 'assistant', + }, }, - callUpstream, + ], + usage: { completion_tokens: 20, prompt_tokens: 200 }, + }); + + const sideRequest = (maxUses?: number) => ({ + max_tokens: 2048, + messages: [ + { + role: 'user' as const, + content: 'Perform a web search for the query: OpenAI updates 2026', + }, + ], + tool_choice: { type: 'tool', name: 'web_search' }, + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + ...(maxUses === undefined ? {} : { max_uses: maxUses }), + input_schema: {}, + }, + ], + }); + + it('honours the forced tool_choice, then loosens it so the model may answer', async () => { + await enableSearch(); + const { run, sent } = routed( + [searchCall('OpenAI updates 2026', 'call_1'), answer('Here it is.')], + sideRequest(8), + ); + + const response = await run(); + + expect(sent[0]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'web_search' }, }); + // Left pinned, the model would be forced to search forever. + expect(sent[1]?.tool_choice).toBe('auto'); + // The server tool stays available: refusals to answer are the model's call. + expect((sent[1]?.tools as unknown[]) ?? []).toHaveLength(1); - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string | null } }>; + const payload = (await response.json()) as { + content: Array<{ type: string }>; + stop_reason: string; }; - const content = payload.choices[0]?.message.content ?? ''; - - // Both the title and the snippet are capped, marked with an ellipsis. - expect(content).toContain('…'); - expect(content.length).toBeLessThan(1400); + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.stop_reason).toBe('end_turn'); }); - it('carries findings without prior text in a mixed turn', async () => { + it('streams prose written before the search, ahead of the search blocks', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ - results: [ - { content: 'snippet', title: 'T', url: 'https://t.test' }, - ], - }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"x"}', - name: 'web_search', + const { run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', + }, }, - }, - { - id: 'call_read', - function: { arguments: '{}', name: 'read_file' }, - }, - ], + ], + }, }, - }, - ], - }), + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.'), + ], + { ...sideRequest(8), stream: true }, ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string | null } }>; - }; + const response = await run(); + const events = await readEvents(response); + const types = events + .filter((event) => event.event === 'content_block_start') + .map( + (event) => + (JSON.parse(event.data) as { content_block: { type: string } }) + .content_block.type, + ); - expect(payload.choices[0]?.message.content).toContain('https://t.test'); + // What was written before the search, then the search, then the answer. + expect(types).toEqual([ + 'thinking', + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(JSON.stringify(events)).toContain('Let me look that up.'); + expect(JSON.stringify(events)).toContain('Here it is.'); }); - it('returns the upstream error response unchanged', async () => { + it('renders the answer’s own reasoning after the search blocks', async () => { await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ error: { message: 'boom' } }, 502), + const { run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.', 'The results answer it.'), + ], + sideRequest(8), ); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); + const payload = (await run().then((r) => r.json())) as { + content: Array<{ thinking?: string; type: string }>; + }; - expect(result?.response?.ok).toBe(false); - expect(result?.response?.status).toBe(502); + // The reasoning that produced the answer belongs after the searches it + // followed, not alongside the one that asked for them. + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'thinking', + 'text', + ]); + expect(payload.content[2].thinking).toBe('The results answer it.'); }); - it('relaxes a forced tool_choice so the loop can terminate', async () => { + it('renders prose written before the search, ahead of the search blocks', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, + const { run } = routed( + [ + { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'web_search', }, - ], - }, + }, + ], }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'done' } }, - ], - }); - }); + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.'), + ], + sideRequest(8), + ); - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tool_choice: { function: { name: 'web_search' }, type: 'function' }, - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); + const payload = (await run().then((r) => r.json())) as { + content: Array<{ text?: string; thinking?: string; type: string }>; + }; - const secondCall = callUpstream.mock.calls[1]?.[0] as ChatRequestBody; - expect(secondCall.tool_choice).toBe('auto'); + // What was written before the search, then the search, then the answer. + expect(payload.content.map((block) => block.type)).toEqual([ + 'thinking', + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.content[0].thinking).toBe('The user wants recent news.'); + expect(payload.content[1].text).toBe('Let me look that up.'); + expect(payload.content[4].text).toBe('Here it is.'); }); - it('recognises a plain function tool named web_search', async () => { + /** + * Prose written *between* two searches. It used to be dropped: a single + * preamble can only hold the first hop's, so a turn like + * text → search → text → search → answer lost the second passage. + */ + it('keeps the prose written between two searches, in place', async () => { await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), - ); + let hop = 0; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'function', function: { name: 'web_search' } }], - }, - callUpstream, - }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); - expect(result).not.toBeNull(); - }); + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } - it('ignores non-object tool declarations', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), - ); + hop += 1; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: ['web_search_preview'], - }, - callUpstream, - }); + if (hop === 1) { + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'First, the broad picture.', + role: 'assistant', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + arguments: '{"query":"quantum computing"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }) as unknown as Response; + } - // A string tool cannot be a search declaration, so the loop stands down. - expect(result).toBeNull(); - expect(callUpstream).not.toHaveBeenCalled(); - }); + if (hop === 2) { + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Now the 2026 announcements.', + role: 'assistant', + tool_calls: [ + { + id: 'c2', + type: 'function', + function: { + arguments: '{"query":"IBM quantum 2026"}', + name: 'web_search', + }, + }, + ], + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }) as unknown as Response; + } - it('accepts a tool that only names web_search without a type', async () => { - await enableSearch(); - const callUpstream = vi.fn(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }), - ); + return makeJsonResponse(answer('Both are covered now.')); + }); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ name: 'web_search_preview' }], + const payload = (await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 2048, + messages: [{ role: 'user', content: 'summarise quantum progress' }], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + input_schema: {}, + }, + ], }, - callUpstream, - }); + ).then((r) => r.json())) as { + content: Array<{ text?: string; type: string }>; + usage: { server_tool_use: { web_search_requests: number } }; + }; - expect(result).not.toBeNull(); + // Each passage sits immediately before the search it motivated. + expect(payload.content.map((block) => block.type)).toEqual([ + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + expect(payload.content[0].text).toBe('First, the broad picture.'); + expect(payload.content[3].text).toBe('Now the 2026 announcements.'); + expect(payload.content[6].text).toBe('Both are covered now.'); + expect(payload.usage.server_tool_use.web_search_requests).toBe(2); }); - it('handles a search call with no arguments', async () => { + it('bills the whole turn, not just the answering hop', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, + const { run } = routed( + [searchCall('OpenAI updates 2026', 'call_1'), answer('Here it is.')], + sideRequest(8), ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [{ function: { name: 'web_search' } }], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }); - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); + const response = await run(); + const payload = (await response.json()) as { + stop_reason: string; + usage: { + input_tokens: number; + output_tokens: number; + server_tool_use: { web_search_requests: number }; + }; + }; - const toolMessage = ( - (callUpstream.mock.calls[1]?.[0] as ChatRequestBody).messages ?? [] - ) - .filter((message) => message.role === 'tool') - .at(-1) as { content?: unknown }; - expect(String(toolMessage.content)).toContain('without a query'); - expect(result?.response?.ok).toBe(true); + // 100+200 prompt and 10+20 completion, both hops. + expect(payload.usage.input_tokens).toBe(300); + expect(payload.usage.output_tokens).toBe(30); + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); }); - it('falls back to raw argument text when JSON is malformed', async () => { + it('stops searching at the max_uses the client declared', async () => { await enableSearch(); - const fetchMock = vi.fn(async () => makeJsonResponse({ results: [] })); - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { arguments: 'not json', name: 'web_search' }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }); + const { run, calls } = routed( + [ + searchCall('one', 'call_1'), + searchCall('two', 'call_2'), + searchCall('three', 'call_3'), + ], + sideRequest(2), + ); - await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); + const response = await run(); + const payload = (await response.json()) as { + content: Array<{ type: string }>; + stop_reason: string; + usage: { server_tool_use: { web_search_requests: number } }; + }; - const [url] = fetchMock.mock.calls[0] as unknown as [string]; - expect(url).toContain('q=not+json'); + expect(payload.usage.server_tool_use.web_search_requests).toBe(2); + // Two searches, then the closing call with the tool withdrawn, then stop. + expect(calls()).toBe(3); + // Nothing leaks: the client never declared a `web_search` function. + expect(payload.content.map((block) => block.type)).not.toContain( + 'tool_use', + ); + expect(payload.stop_reason).toBe('end_turn'); }); - it('keeps the first usage when a later iteration omits it', async () => { + it('searches repeatedly and reports every search', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, + const { run, calls } = routed( + [ + searchCall('one', 'call_1'), + searchCall('two', 'call_2'), + answer('Done.'), + ], + sideRequest(8), ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { prompt_tokens: 4, total_tokens: 9 }, - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - }); - }); + const response = await run(); + const payload = (await response.json()) as { + content: Array<{ type: string }>; + usage: { server_tool_use: { web_search_requests: number } }; + }; - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], + expect(calls()).toBe(3); + expect(payload.usage.server_tool_use.web_search_requests).toBe(2); + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'server_tool_use', + 'web_search_tool_result', + 'text', + ]); + }); + + it('runs the server tool while leaving the client’s own WebSearch alone', async () => { + await enableSearch(); + const { run } = routed( + [searchCall('OpenAI updates 2026', 'call_1'), answer('Here it is.')], + { + max_tokens: 2048, + messages: [{ role: 'user', content: 'lookup' }], + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 8, + input_schema: {}, + }, + { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, + }, + ], }, - callUpstream, - }); + ); - const payload = (await readPayload(result)) as { - usage: { prompt_tokens?: number }; + const response = await run(); + const payload = (await response.json()) as { + usage: { server_tool_use: { web_search_requests: number } }; }; - expect(payload.usage.prompt_tokens).toBe(4); - }); - it('adopts usage when the first iteration reports none', async () => { + // The client declaring WebSearch used to look like a name collision and + // switch the whole feature off. + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); + }); + + /** + * Both declarations in one request, and the model calls both on the first + * hop — the one request where §23-A and §23-B conflict. + * + * `WebSearch` and `web_search` are the same string once case and separators + * are stripped, so only the declared type can tell them apart. Get it wrong + * in either direction and Claude Code loses: run the client's `WebSearch` + * here and it never receives the `tool_use` it needs (§26), or withhold the + * server tool and the side request it is waiting on never searches. + */ + it('runs the server tool and still hands the client’s own WebSearch back', async () => { await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - usage: { prompt_tokens: 3, total_tokens: 6 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - usage: { prompt_tokens?: number }; - }; - expect(payload.usage.prompt_tokens).toBe(3); - }); - - it('sums usage across loop iterations', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - let call = 0; - const callUpstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { prompt_tokens: 10, total_tokens: 20 }, - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], - usage: { prompt_tokens: 5, total_tokens: 8 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - const payload = (await readPayload(result)) as { - usage: { prompt_tokens?: number; total_tokens?: number }; - }; - expect(payload.usage.prompt_tokens).toBe(15); - expect(payload.usage.total_tokens).toBe(28); - }); - - it('returns the error when the final call without search tools fails', async () => { - await enableSearch(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - makeJsonResponse({ results: [] }), - ) as unknown as typeof fetch, - ); - const callUpstream = vi.fn(async (body) => { - const hasSearchTool = ( - (body.tools ?? []) as Array<{ - function?: { name?: string }; - }> - ).some((tool) => tool.function?.name === 'web_search'); - - return hasSearchTool - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'c1', - function: { - arguments: '{"query":"x"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ error: { message: 'late failure' } }, 500); - }); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - tools: [{ type: 'web_search_preview' }], - }, - callUpstream, - }); - - expect(result?.response?.status).toBe(500); - }); - }); - - describe('stream synthesis', () => { - it('emits role, content, finish, and usage events', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'stop', - message: { content: 'Hello there', role: 'assistant' }, - }, - ], - created: 42, - id: 'chatcmpl_test', - model: 'glm-5.1', - usage: { total_tokens: 7 }, - }, - 'fallback-model', - ); - - expect(response.headers.get('content-type')).toContain( - 'text/event-stream', - ); - - const events = await readSseEvents(response); - expect(events.at(-1)).toBe('[DONE]'); - - const parsed = events - .filter((event) => event !== '[DONE]') - .map((event) => JSON.parse(event) as Record); - - expect(parsed[0]).toMatchObject({ - choices: [{ delta: { role: 'assistant' } }], - id: 'chatcmpl_test', - model: 'glm-5.1', - object: 'chat.completion.chunk', - }); - expect(JSON.stringify(parsed)).toContain('Hello there'); - expect(JSON.stringify(parsed.at(-2))).toContain('"finish_reason":"stop"'); - expect(JSON.stringify(parsed.at(-1))).toContain('"total_tokens":7'); - }); - - it('chunks long content across multiple deltas', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'stop', - message: { content: 'x'.repeat(2500), role: 'assistant' }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const contentEvents = events.filter((event) => - event.includes('"content":"x'), - ); - - expect(contentEvents.length).toBeGreaterThan(1); - }); - - it('emits reasoning content and passes through tool calls', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - reasoning_content: 'Let me check.', - tool_calls: [ - { - function: { arguments: '{}', name: 'read_file' }, - type: 'function', - }, - ], - }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const parsed = events - .filter((event) => event !== '[DONE]') - .map((event) => JSON.parse(event) as Record); - - expect(JSON.stringify(parsed)).toContain('Let me check.'); - expect(JSON.stringify(parsed)).toContain('read_file'); - expect(JSON.stringify(parsed.at(-1))).toContain( - '"finish_reason":"tool_calls"', - ); - }); - - it('omits a usage event when the payload has none', async () => { - const response = synthesizeChatCompletionStream( - { choices: [{ finish_reason: 'stop', message: { content: 'hi' } }] }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - - expect(JSON.stringify(events)).not.toContain('"usage"'); - expect(events.at(-1)).toBe('[DONE]'); - }); - - it('injects an id and type for tool calls that omit them', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: null, - message: { - content: '', - tool_calls: [{ function: { arguments: '{}', name: 'go' } }], - }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const parsed = events - .filter((event) => event !== '[DONE]') - .map((event) => JSON.parse(event) as Record); - const toolEvent = JSON.parse( - events.find((event) => event.includes('"tool_calls"')) ?? '{}', - ) as { - choices: Array<{ - delta: { tool_calls: Array> }; - }>; - }; - const toolCall = toolEvent.choices[0]?.delta.tool_calls?.[0]; - - expect(toolCall?.id).toMatch(/^call_/); - expect(toolCall?.type).toBe('function'); - expect(toolCall?.index).toBe(0); - // No text was produced, so no content delta is emitted. With no usage - // block the finish event is the last one before [DONE]. - expect(JSON.stringify(parsed)).not.toContain('"content":""'); - expect(JSON.stringify(parsed.at(-1))).toContain( - '"finish_reason":"tool_calls"', - ); - }); - - it('skips reasoning when only the reasoning field is set', async () => { - const response = synthesizeChatCompletionStream( - { - choices: [ - { - finish_reason: 'stop', - message: { content: 'hi', reasoning: 'step one' }, - }, - ], - }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - - expect(JSON.stringify(events)).toContain('step one'); - }); - - it('falls back to the provided model and a generated id', async () => { - const response = synthesizeChatCompletionStream( - { choices: [{ finish_reason: 'stop', message: { content: 'hi' } }] }, - 'fallback-model', - ); - - const events = await readSseEvents(response); - const first = JSON.parse(events[0] ?? '{}') as Record; - - expect(first.model).toBe('fallback-model'); - expect(String(first.id)).toMatch(/^chatcmpl_/); - }); - }); - - describe('upstream streaming contract', () => { - const enableSearxngSearch = async (): Promise => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - makeJsonResponse({ - results: [ - { - content: 'Search result', - title: 'Result', - url: 'https://result.test', - }, - ], - }), - ); - }; - - const inlineBody = (): ChatRequestBody => ({ - messages: [{ content: 'Search', role: 'user' }], - stream: true, - tools: [{ type: 'web_search_preview' }], - }); - - it('hands a client call from a buffered iteration back to the client', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse({ + const { calls, run } = routed( + [ + { choices: [ { - delta: { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', tool_calls: [ { + id: 'call_server', + type: 'function', function: { - arguments: '{"query":"mixed"}', + arguments: '{"query":"OpenAI updates 2026"}', name: 'web_search', }, }, + { + id: 'call_client', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates 2026"}', + name: 'WebSearch', + }, + }, ], }, - finish_reason: 'tool_calls', - index: 0, }, ], - }); - } - - // A buffered iteration can mix a server tool with a client-owned one: - // the server tool runs here, the client's is handed back unanswered. - return makeJsonResponse({ - choices: [ + usage: { completion_tokens: 10, prompt_tokens: 100 }, + }, + answer('Here it is.'), + ], + { + max_tokens: 2048, + messages: [ { - finish_reason: 'tool_calls', - message: { - content: 'Checking both.', - tool_calls: [ - { - function: { - arguments: '{"query":"second"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, + role: 'user', + content: + 'Perform a web search for the query: OpenAI updates 2026', }, ], - }); - }); + tools: [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 8, + input_schema: {}, + }, + { + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, + }, + ], + }, + ); - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); + const payload = (await run().then((r) => r.json())) as { + content: Array<{ name?: string; type: string }>; + stop_reason: string; + usage: { server_tool_use: { web_search_requests: number } }; + }; - expect(text).toContain('Checking both.'); - expect(text).toContain('client_tool'); - expect(text).toContain('"finish_reason":"tool_calls"'); - }); + // The declared server tool really ran, against the provider. + expect(providerQueries()).toHaveLength(1); + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); - it('ends the turn when a follow-up iteration stops calling server tools', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; + // ...and it is reported structurally, never as a `tool_use` — a client + // that declared a provider-executed tool has no handler for one. + expect(payload.content.map((block) => block.type)).toEqual([ + 'server_tool_use', + 'web_search_tool_result', + 'tool_use', + ]); + expect(payload.content[0].name).toBe('web_search'); - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"only hop"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // No server tool this time: the held frames are forwarded untouched - // and the turn is over. - return makeSseResponse( - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_client', - index: 0, - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - index: 0, - }, - ], - }, - { choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }] }, - ); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('client_tool'); - expect(text).toContain('"finish_reason":"tool_calls"'); - expect(upstream).toHaveBeenCalledTimes(2); - }); - - it('keeps malformed frames and returns mixed client tool calls', async () => { - await enableSearxngSearch(); - const upstream = vi.fn(async () => { - const toolChunk = { - choices: [ - { - delta: { - content: 'Searching.', - reasoning_content: 'Need current data.', - tool_calls: [ - { - function: { - arguments: '{"query":"mixed tools"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - index: 1, - function: { - arguments: '{"city":"Shenzhen"}', - name: 'get_weather', - }, - type: 'function', - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - created: 123, - id: 'chatcmpl_mixed', - model: 'hy4-dev', - object: 'chat.completion.chunk', - }; - - return new Response( - `event: ping\n\ndata:\n\ndata: ${JSON.stringify(toolChunk)}\n\ndata: [DONE]`, - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('event: ping'); - expect(text).toContain('Need current data.'); - expect(text).toContain('server_tool_0_0'); - expect(text).toContain('Search result'); - expect(text).toContain('get_weather'); - expect(text).toContain('data: [DONE]'); - expect(upstream).toHaveBeenCalledTimes(1); - }); - - it('aggregates usage when the turn after a streamed tool call completes', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse( - { - choices: [ - { - delta: { - reasoning_content: 'Need usage data.', - tool_calls: [ - { index: 0, function: { name: 'web_search' } }, - ], - }, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_usage', - index: 0, - function: { arguments: '{"query":"usage"}' }, - }, - ], - }, - index: 0, - }, - ], - usage: { - completion_tokens: 1, - prompt_tokens: 2, - total_tokens: 3, - }, - }, - { - choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }], - }, - ); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Final answer.' } }, - ], - usage: { completion_tokens: 4, prompt_tokens: 5, total_tokens: 9 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const events = await readSseEvents(result!.response!); - const usageEvent = events - .map((event) => (event === '[DONE]' ? null : JSON.parse(event))) - .find((event) => event?.usage); - - expect(usageEvent?.usage).toEqual({ - completion_tokens: 5, - prompt_tokens: 7, - total_tokens: 12, - }); - expect(JSON.stringify(events)).toContain('Final answer.'); - expect(JSON.stringify(events)).toContain('call_usage'); - expect(upstream).toHaveBeenCalledTimes(2); - }); - - it('returns a later upstream error inside the composite stream', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ error: { message: 'follow-up failed' } }, 502); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).resolves.toContain( - 'follow-up failed', - ); - }); - - it('synthesizes an error for an empty later upstream failure', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : new Response(null, { status: 502 }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).resolves.toContain( - 'Upstream request failed with status 502', - ); - }); - - it('rejects invalid JSON from a successful later upstream response', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - return call === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"invalid follow-up"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : new Response('not json', { status: 200 }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).rejects.toThrow(); - }); - - it('returns later mixed client calls after executing another server tool', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async () => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_first', - index: 0, - function: { - arguments: '{"query":"first"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Use both results.', - tool_calls: [ - { - function: { - arguments: '{"query":"second"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { - arguments: '{}', - name: 'client_tool', - }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('server_tool_1_0'); - expect(text).toContain('client_tool'); - expect(text).toContain('Search result'); - expect(upstream).toHaveBeenCalledTimes(2); - }); - - it('drops local tools after the inline iteration budget is exhausted', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { total_tokens: 1 }, - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Budget answer.' } }, - ], - usage: { total_tokens: 2 }, - }); - }); - const body = inlineBody(); - body.tool_choice = { function: { name: 'web_search' }, type: 'function' }; - - const result = await executeWebSearchLoop({ - body, - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - const finalBody = upstream.mock.calls.at(-1)?.[0]; - - expect(text).toContain('Budget answer.'); - expect(finalBody?.tools).toEqual([]); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('streams the budget fallback answer once local tools are dropped', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // The budget is spent and every server tool is gone, so this answer - // ends the turn. It is streamed, so it must reach the client as-is - // rather than being buffered into a payload. - return makeSseResponse({ - choices: [{ delta: { content: 'Budget stream.' }, index: 0 }], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - const finalBody = upstream.mock.calls.at(-1)?.[0]; - - expect(text).toContain('Budget stream.'); - expect(finalBody?.tools).toEqual([]); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('uses a JSON budget fallback answer when upstream does not stream', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - usage: { total_tokens: 1 }, - }); - } - - // A non-streaming upstream still has to produce a usable answer once - // the budget is spent. - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'JSON fallback.' } }, - ], - usage: { total_tokens: 4 }, - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('JSON fallback.'); - // Usage accumulates across every iteration of the loop. - expect(text).toContain('"total_tokens":8'); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('hands a client tool call from the budget fallback back to the client', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // Every server tool was stripped, so a tool call here can only be the - // client's own: it has to be handed back rather than executed. - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_client', - index: 0, - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const text = await result!.response!.text(); - - expect(text).toContain('client_tool'); - expect(text).toContain('"finish_reason":"tool_calls"'); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('stops the budget fallback when the client disconnects', async () => { - await enableSearxngSearch(); - const encoder = new TextEncoder(); - let call = 0; - let resolveCancel: (() => void) | undefined; - let upstreamCancelled: Promise | undefined; - - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"loop 0"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - function: { - arguments: `{"query":"loop ${call - 1}"}`, - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // The fallback answer never finishes, so only a client-side - // cancellation can end this turn. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Fallback.' }, index: 0 }], - })}\n\n`, - ), - ); - upstreamCancelled = new Promise((resolve) => { - resolveCancel = () => resolve(true); - }); - }, - cancel: () => { - resolveCancel?.(); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - const reader = result!.response!.body!.getReader(); - const decoder = new TextDecoder(); - let seen = ''; - - while (!seen.includes('Fallback.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - seen += decoder.decode(chunk.value); - } - - await reader.cancel(); - - // The disconnect has to reach the parked upstream read, not just the - // downstream stream: a stalled upstream would otherwise stay alive. - await expect(upstreamCancelled).resolves.toBe(true); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('returns an error when the final budget fallback fails upstream', async () => { - await enableSearxngSearch(); - let call = 0; - const upstream = vi.fn(async (body) => { - call += 1; - - if (call === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_initial', - index: 0, - function: { - arguments: '{"query":"fallback error"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (body.tools?.length) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: `call_${call}`, - function: { - arguments: '{"query":"again"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ error: { message: 'fallback failed' } }, 502); - }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: upstream, - }); - - await expect(result!.response!.text()).resolves.toContain( - 'fallback failed', - ); - expect(upstream).toHaveBeenCalledTimes(6); - }); - - it('leaves an empty non-SSE initial response untouched', async () => { - await enableSearxngSearch(); - const response = new Response(null, { status: 204 }); - - const result = await executeWebSearchLoop({ - body: inlineBody(), - callbacks: { emitStreamEvents: true }, - callUpstream: async () => response, - }); - - expect(result?.response).toBe(response); - await expect(result!.response!.text()).resolves.toBe(''); - }); - - it('always asks upstream to stream and buffers the response', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - const upstreamBodies: Array> = []; - let call = 0; - - const fetchMock = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [{ content: 'snip', title: 'T', url: 'https://t.test' }], - }); - } - - call += 1; - upstreamBodies.push(JSON.parse(String(init?.body ?? '{}'))); - - // Upstream only ever speaks SSE. - const chunk = - call === 1 - ? { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_1', - index: 0, - type: 'function', - function: { - arguments: '{"query":"q1"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - } - : { - choices: [ - { delta: { content: 'Done.' }, finish_reason: 'stop' }, - ], - }; - - return new Response( - `data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, - { - status: 200, - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - }, - }, - ); - }, - ); - - vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); - - const result = await executeWebSearchLoop({ - body: { - messages: [{ content: 'hi', role: 'user' }], - stream: false, - tools: [{ type: 'web_search_preview' }], - }, - callUpstream: (loopBody) => - proxyChatCompletions( - new NextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - loopBody, - createProxyContextFromCredential({ - data: { - bearer_token: 'stream-token', - user_id: 'stream@example.com', - }, - filePath: '/tmp/stream.json', - filename: 'stream.json', - }), - ), - }); - - // Upstream must never receive stream:false — it answers 11101. - expect(upstreamBodies.length).toBeGreaterThan(0); - for (const body of upstreamBodies) { - expect(body.stream).toBe(true); - } - - // The buffered result is still a normal JSON completion for the loop. - const payload = (await readPayload(result)) as { - choices: Array<{ message: { content: string } }>; - }; - expect(payload.choices[0]?.message.content).toBe('Done.'); - }); - }); - - describe('config gating', () => { - it('labels the backend selector in every locale', () => { - expect(getSettingLabels('en-US').CODEBUDDY_WEB_SEARCH_BACKEND).toBe( - 'Web search backend', - ); - expect(getSettingLabels('zh-CN').CODEBUDDY_WEB_SEARCH_BACKEND).toBe( - 'WebSearch 后端', - ); - expect(getSettingLabels('ja-JP').CODEBUDDY_WEB_SEARCH_BACKEND).toBe( - 'Web 検索バックエンド', - ); - }); - - it('has no separate enable switch', () => { - // `none` is the off state, so a second control could only contradict it. - expect(getSettingLabels('en-US')).not.toHaveProperty( - 'CODEBUDDY_WEB_SEARCH_ENABLED', - ); - }); - - it('keeps the setting disabled when the backend is none', async () => { - // No SEARXNG_URL here, so `searxng` cannot be built — but `isWebSearchEnabled` - // only reflects the configured choice; the provider resolution is what - // degrades it to nothing. - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); - - await expect(isWebSearchEnabled()).resolves.toBe(false); - }); - - it('degrades to no provider when the chosen backend is unconfigured', async () => { - // `searxng` is selected but SEARXNG_URL is unset, so no provider can be - // built and the tool is not advertised to the model. - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - - expect( - resolveSearchProvider('searxng', async () => 'https://cb.test'), - ).toBeNull(); - }); - - it('accepts the backend values from the console', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - await expect(isWebSearchEnabled()).resolves.toBe(true); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); - await expect(isWebSearchEnabled()).resolves.toBe(false); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - await expect(isWebSearchEnabled()).resolves.toBe(true); - }); - - it('reads the setting from the environment when nothing is persisted', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - process.env.CODEBUDDY_WEB_SEARCH_BACKEND = 'searxng'; - resetWebSearchProviders(); - - await expect(isWebSearchEnabled()).resolves.toBe(true); - - delete process.env.CODEBUDDY_WEB_SEARCH_BACKEND; - }); - - it('reflects the backend choice', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - await expect(isWebSearchEnabled()).resolves.toBe(true); - - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'passthrough' }); - await expect(isWebSearchEnabled()).resolves.toBe(false); - }); - }); -}); - -describe('responses tool translation', () => { - beforeEach(() => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - }); - - afterEach(() => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - }); - - it('translates web_search_preview without requiring SearXNG', () => { - const tools = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual(['web_search']); - }); - - it('translates web_search_preview into a callable function when configured', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const tools = translateResponsesToolsToChat([ - { type: 'web_search_preview' }, - { type: 'function', name: 'read_file' }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual([ - 'web_search', - 'read_file', - ]); - }); - - it('accepts dated Anthropic-style server tool types', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const tools = translateResponsesToolsToChat([ - { type: 'web_search_20260209', name: 'web_search' }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual(['web_search']); - }); - - it('preserves search tools nested in a namespace', () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - - const tools = translateResponsesToolsToChat([ - { - type: 'namespace', - name: 'docs', - tools: [{ type: 'web_search_preview' }], - }, - ]) as Array<{ function: { name: string } }>; - - expect(tools.map((tool) => tool.function.name)).toEqual(['web_search']); - }); -}); - -describe('chat proxy web search integration', () => { - const repoRoot = process.cwd(); - const tempRootDir = path.join(repoRoot, '.tmp-websearch-proxy-root'); - const tempAccessKeysPath = path.join( - tempRootDir, - '.codebuddy_data', - 'access-keys.json', - ); - - const cleanup = (): void => { - fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); - }; - - const makeNextRequest = ( - url: string, - init?: ConstructorParameters[1], - ): NextRequest => new NextRequest(url, init); - - beforeEach(async () => { - cleanup(); - resetCredentialRuntimeState(); - await resetUsageStats(); - vi.restoreAllMocks(); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); - fs.rmSync(tempAccessKeysPath, { force: true }); - await addCredential({ - bearer_token: 'websearch-test-token', - first_message_role_to_system: false, - responses_passthrough: false, - user_id: 'websearch@example.com', - }); - process.env.CODEBUDDY_AUTH_MODE = 'token'; - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - }); - - afterEach(() => { - cleanup(); - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - vi.useRealTimers(); - }); - - it('serves a synthesized SSE stream when a streaming client triggers search', async () => { - const upstreamCalls: string[] = []; - vi.spyOn(globalThis, 'fetch').mockImplementation( - async (input: RequestInfo | URL) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [ - { content: 'Found it', title: 'Docs', url: 'https://docs.test' }, - ], - }) as unknown as Response; - } - - upstreamCalls.push(url); - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'It is sunny.', role: 'assistant' }, - }, - ], - model: 'glm-5.1', - }) as unknown as Response; - }, - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'weather today?' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain('text/event-stream'); - - const text = await response.text(); - expect(text).toContain('"content":"It is sunny."'); - expect(text).toContain('data: [DONE]'); - // The loop ran before the stream was synthesized. - expect(upstreamCalls.length).toBeGreaterThan(0); - }); - - it('keeps ordinary replies live when server web tools are available', async () => { - const encoder = new TextEncoder(); - let releaseFinalChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - throw new Error('Search should not run for an ordinary reply'); - } - - return new Response( - new ReadableStream({ - start: (controller) => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'First chunk.' }, index: 0 }], - })}\n\n`, - ), - ); - releaseFinalChunk = () => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { content: ' Final chunk.' }, - finish_reason: 'stop', - index: 0, - }, - ], - })}\n\ndata: [DONE]\n\n`, - ), - ); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, - ); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'say hello' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - const reader = response.body!.getReader(); - const first = await reader.read(); - - expect(new TextDecoder().decode(first.value)).toContain('First chunk.'); - expect(releaseFinalChunk).toBeTypeOf('function'); - - releaseFinalChunk!(); - - const remainder: string[] = []; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - remainder.push(new TextDecoder().decode(chunk.value)); - } - - expect(remainder.join('')).toContain('Final chunk.'); - expect(remainder.join('')).toContain('data: [DONE]'); - }); - - it('keeps a passthrough fetch live when search executes locally', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'passthrough', - CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', - }); - const encoder = new TextEncoder(); - let releaseFinalChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - throw new Error('Search should not run for a passthrough fetch'); - } - - return new Response( - new ReadableStream({ - start: (controller) => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_fetch', - index: 0, - function: { - arguments: '{"url":"https://page.test"}', - name: 'web_fetch', - }, - }, - ], - }, - index: 0, - }, - ], - })}\n\n`, - ), - ); - releaseFinalChunk = () => { - controller.enqueue(encoder.encode('data: [DONE]\n\n')); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } }, - ); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'read the page' }], - stream: true, - tools: [ - { type: 'web_search_20260209', name: 'web_search' }, - { type: 'web_fetch_20250910', name: 'web_fetch' }, - ], - }, - ); - const reader = response.body!.getReader(); - const first = await reader.read(); - - expect(new TextDecoder().decode(first.value)).toContain('web_fetch'); - expect(releaseFinalChunk).toBeTypeOf('function'); - releaseFinalChunk!(); - await reader.cancel(); - }); - - it('replays ignorable SSE frames before ordinary content', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - [ - ': keepalive\n\n', - 'data: \n\n', - 'data: {invalid\n\n', - `data: ${JSON.stringify({})}\n\n`, - `data: ${JSON.stringify({ - choices: [{ delta: { tool_calls: [{ function: {} }] } }], - })}\n\n`, - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Ordinary answer.' }, index: 0 }], - })}\n\n`, - 'data: [DONE]\n\n', - ].join(''), - { headers: { 'Content-Type': 'text/event-stream' } }, - ), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Say hello' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(await response.text()).toContain('Ordinary answer.'); - }); - - it('passes through a stream that ends before meaningful content', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response('data: [DONE]\n\n', { - headers: { 'Content-Type': 'text/event-stream' }, - }), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Say nothing' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(await response.text()).toContain('data: [DONE]'); - }); - - it.each([ - ['id', { id: 'call_search' }], - ['position', {}], - ])('detects fragmented local tool names keyed by %s', async (_label, key) => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { - tool_calls: [{ ...key, function: { name: 'web_' } }], - }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - ...key, - function: { - arguments: '{"query":"fragments"}', - name: 'search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Found.' } }, - ], - }); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Search fragments' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(await response.text()).toContain('Found.'); - expect(upstreamCalls).toBe(2); - }); - - it('preserves an upstream SSE response without a body', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { - headers: { 'Content-Type': 'text/event-stream' }, - status: 204, - }), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'No body' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(response.status).toBe(204); - expect(response.body).toBeNull(); - }); - - it('propagates an upstream failure after replay starts', async () => { - const encoder = new TextEncoder(); - let failStream: (() => void) | undefined; - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - new ReadableStream({ - start: (controller) => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Partial.' }, index: 0 }], - })}\n\n`, - ), - ); - failStream = () => controller.error(new Error('stream failed')); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ), - ); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ role: 'user', content: 'Fail later' }], - stream: true, - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }, - ); - - expect(failStream).toBeTypeOf('function'); - failStream!(); - await expect(response.text()).rejects.toThrow('stream failed'); - }); - - it('emits Responses web_search_call lifecycle before the final message', async () => { - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { - content: 'Current result', - title: 'News', - url: 'https://news.test', - }, - ], - }); - } - - upstreamCalls++; - if (upstreamCalls === 1) { - return makeSseResponse( - { - choices: [ - { - delta: { reasoning_content: 'I need current information.' }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { name: 'web_' }, - }, - ], - }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '{"query":"latest news"}', - name: 'search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Latest answer.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'What is new?', - instructions: 'Use current sources.', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - const events = text - .split('\n\n') - .flatMap((frame) => - frame - .split('\n') - .filter((line) => line.startsWith('data: ')) - .map((line) => line.slice(6)), - ) - .filter((payload) => payload !== '[DONE]') - .map((payload) => JSON.parse(payload) as Record); - const addedItems = events.filter( - (event) => event.type === 'response.output_item.added', - ) as Array<{ - item: { type: string }; - output_index: number; - }>; - const completed = events.find( - (event) => event.type === 'response.completed', - ) as { - response: { output: Array<{ type: string }> }; - }; - - expect(text).toContain('"type":"web_search_call"'); - expect(text).toContain('"type":"response.web_search_call.in_progress"'); - expect(text).toContain('"type":"response.web_search_call.searching"'); - expect(text).toContain('"type":"response.web_search_call.completed"'); - expect(text).toContain('"action":{"type":"search","query":"latest news"}'); - expect(text).not.toContain( - '"type":"function_call","call_id":"call_search"', - ); - expect(text.indexOf('"type":"web_search_call"')).toBeLessThan( - text.indexOf('Latest answer.'), - ); - expect(new Set(addedItems.map((event) => event.output_index)).size).toBe( - addedItems.length, - ); - expect(completed.response.output.map((item) => item.type)).toEqual( - addedItems.map((event) => event.item.type), - ); - }); - - it('leaves a client-declared web_fetch to the client on the Responses route', async () => { - // The Responses API has no `web_fetch` server tool — only `web_search`. A - // client that declares one as a plain function owns it and resolves it - // itself, and no backend setting changes that: the setting chooses who runs - // the *proxy's* tool, not whether the proxy may take the client's. - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - let pageFetches = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch') || url.includes('page.test')) { - pageFetches += 1; - - return makeJsonResponse({ content: 'Fetched body.' }); - } - - upstreamCalls += 1; - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Fetch the page', - tools: [{ type: 'function', name: 'web_fetch' }], - }, - ); - const body = await response.text(); - - // The proxy neither fetched nor re-asked upstream: the call is handed back - // for the client to resolve. - expect(pageFetches).toBe(0); - expect(upstreamCalls).toBe(1); - expect(body).not.toContain('open_page'); - }); - - it('streams a Responses open_page lifecycle for local fetch', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched stream.' }); - } - - if (url.includes('stream.test')) { - throw new Error('The endpoint result should win the local fallback'); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { reasoning_content: 'I need to read the page.' }, - finish_reason: null, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"url":"https://stream.test/page"}', - name: 'webfetch', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Fetched.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Fetch the page', - stream: true, - tools: [{ type: 'web_fetch_20250910', name: 'web_fetch' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('response.web_search_call.in_progress'); - expect(text).toContain('response.web_search_call.completed'); - expect(text).toContain( - '"action":{"type":"open_page","url":"https://stream.test/page"}', - ); - expect(text).not.toContain('"type":"function_call","call_id"'); - }); - - it('streams ordinary Responses output with instructions', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeSseResponse({ - choices: [ - { - delta: { content: 'Instruction answer.' }, - finish_reason: 'stop', - index: 0, - }, - ], - }), - ); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Answer normally', - instructions: 'Be concise.', - stream: true, - }, - ); - - expect(await response.text()).toContain('Instruction answer.'); - }); - - it('streams Responses search progress before the backend finishes', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"live query"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Live answer.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search live', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let beforeResult = ''; - - while (!beforeResult.includes('response.web_search_call.searching')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - beforeResult += decoder.decode(chunk.value); - } - - expect(finishSearch).toBeTypeOf('function'); - expect(beforeResult).not.toContain('response.web_search_call.completed'); - finishSearch!(makeJsonResponse({ results: [] })); - - let afterResult = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - afterResult += decoder.decode(chunk.value); - } - - expect(afterResult).toContain('response.web_search_call.completed'); - expect(afterResult).toContain('Live answer.'); - }); - - it('streams a Responses error when local tool upstream execution fails', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeJsonResponse({ error: { message: 'upstream failed' } }, 502), - ); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search live', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"response.error"'); - expect(text).toContain('data: [DONE]'); - }); - - it('terminates Responses with an error when a post-tool request fails', async () => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error after search"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ error: { message: 'follow-up failed' } }, 502); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search and fail', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"response.error"'); - // The upstream said why it failed, so its own words are passed through - // rather than the proxy's generic failure message. - expect(text).toContain('follow-up failed'); - expect(text).not.toContain('"type":"response.completed"'); - }); - - it.each([ - ['string', 'follow-up string', 'follow-up string'], - [ - 'message-less object', - { code: 'upstream_error' }, - // No message exists anywhere in the payload, so the raw JSON is kept: - // it still carries `code`, which the generic fallback would have lost. - // The quotes are escaped because the frame is JSON-encoded for SSE. - '{\\"error\\":{\\"code\\":\\"upstream_error\\"}}', - ], - ])( - 'maps a %s post-tool error payload to a terminal Responses error', - async (_label, error, expectedMessage) => { - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"error payload"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ error }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search and report the error', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"response.error"'); - expect(text).toContain(expectedMessage); - expect(text).not.toContain('"type":"response.completed"'); - }, - ); - - it('does not resume a Responses server-tool loop after disconnect', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - const cancelSpy = vi.spyOn(ReadableStream.prototype, 'cancel'); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"cancel response"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Late answer.' } }, - ], - }); - }); - - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Search and disconnect', - stream: true, - tools: [{ type: 'web_search_preview' }], - }, - ); - - await vi.waitFor(() => expect(finishSearch).toBeTypeOf('function')); - await response.body!.cancel(); - const callsAfterClientCancel = cancelSpy.mock.calls.length; - expect(callsAfterClientCancel).toBeGreaterThan(0); - finishSearch!(makeJsonResponse({ results: [] })); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(upstreamCalls).toBe(1); - expect(cancelSpy.mock.calls.length).toBe(callsAfterClientCancel); - }); - - it('drops an unexecutable Anthropic server tool but keeps a client function', async () => { - delete process.env.SEARXNG_URL; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); - const upstreamBodies: Array> = []; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - upstreamBodies.push( - JSON.parse(String(init?.body)) as Record, - ); - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Done.' } }], - }); - }); - - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [{ role: 'user', content: 'Search' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [{ role: 'user', content: 'Use my function' }], - tools: [{ name: 'web_search', input_schema: {} }], - }, - ); - - expect(upstreamBodies[0]?.tools).toEqual([]); - expect(upstreamBodies[1]?.tools).toEqual([ - expect.objectContaining({ - type: 'function', - function: expect.objectContaining({ name: 'web_search' }), - }), - ]); - }); - - it('replays Anthropic server-tool history as paired tool messages', async () => { - let upstreamBody: Record | undefined; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - upstreamBody = JSON.parse(String(init?.body)) as Record; - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Follow-up.' } }, - ], - }); - }); - const encryptedContent = btoa( - JSON.stringify({ - content: 'Search snippet', - title: 'Result title', - url: 'https://result.test', - }), - ); - - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [ - { role: 'user', content: 'Research this' }, - { - role: 'assistant', - content: [ - { - type: 'server_tool_use', - id: 'srv_search', - name: 'web_search', - input: { query: 'current topic' }, - }, - { - type: 'web_search_tool_result', - tool_use_id: 'srv_search', - content: [ - { - type: 'web_search_result', - title: 'Result title', - url: 'https://result.test', - encrypted_content: encryptedContent, - }, - ], - }, - { - type: 'server_tool_use', - id: 'srv_fetch', - name: 'web_fetch', - input: { url: 'https://result.test' }, - }, - { - type: 'web_fetch_tool_result', - tool_use_id: 'srv_fetch', - content: { - type: 'web_fetch_result', - url: 'https://result.test', - content: { - type: 'document', - source: { - type: 'text', - media_type: 'text/plain', - data: 'Fetched body', - }, - }, - }, - }, - { type: 'text', text: 'Initial answer.' }, - ], - }, - { role: 'user', content: 'Continue' }, - ], - }, - ); - - const messages = upstreamBody?.messages as Array>; - expect(messages.slice(1, 6)).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: null, - tool_calls: [expect.objectContaining({ id: 'srv_search' })], - }), - expect.objectContaining({ - role: 'tool', - tool_call_id: 'srv_search', - content: expect.stringContaining('Search snippet'), - }), - expect.objectContaining({ - role: 'assistant', - content: null, - tool_calls: [expect.objectContaining({ id: 'srv_fetch' })], - }), - expect.objectContaining({ - role: 'tool', - tool_call_id: 'srv_fetch', - content: expect.stringContaining('Fetched body'), - }), - expect.objectContaining({ - role: 'assistant', - content: 'Initial answer.', - }), - ]); - expect(JSON.stringify(messages)).not.toContain('encrypted_content'); - expect(JSON.stringify(messages)).not.toContain('server_tool_use'); - }); - - it('replays partial Anthropic server-tool results without leaking opaque data', async () => { - let upstreamBody: Record | undefined; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - upstreamBody = JSON.parse(String(init?.body)) as Record; - - return makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Handled.' } }], - }); - }); - - await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 256, - messages: [ - { - role: 'assistant', - content: [ - { type: 'server_tool_use' }, - { - type: 'web_search_tool_result', - content: [ - null, - { - url: 'https://fallback.test', - snippet: 'Visible snippet', - }, - { title: 'Text title', text: 'Visible text' }, - { - encrypted_content: btoa( - JSON.stringify({ content: 'Decoded only' }), - ), - title: 'Fallback title', - url: 'https://item.test', - }, - { encrypted_content: 'not-base64' }, - ], - }, - { - type: 'server_tool_use', - id: 'fetch_partial', - name: 'web_fetch', - }, - { - type: 'web_fetch_tool_result', - tool_use_id: 'fetch_partial', - content: { url: 42, content: 'missing document' }, - }, - { type: 'text' }, - ], - }, - { role: 'user', content: 'Continue' }, - ], - }, - ); - - const serialized = JSON.stringify(upstreamBody?.messages); - expect(serialized).toContain('Visible snippet'); - expect(serialized).toContain('Visible text'); - expect(serialized).toContain('Decoded only'); - expect(serialized).toContain('"name":"unknown"'); - expect(serialized).not.toContain('encrypted_content'); - }); - - it('returns Anthropic server tool and fetch result blocks', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ - content: 'Fetched article body.', - }); - } - - if (url.includes('page.test')) { - throw new Error('The endpoint result should win the local fallback'); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { name: 'web_' }, - }, - ], - }, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '{"url":"https://page.test/article"}', - name: 'fetch', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Article summary.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [ - { - role: 'user', - content: 'Read https://page.test/article', - }, - ], - stream: true, - tools: [ - { type: 'web_fetch_20250910', name: 'web_fetch', input_schema: {} }, - ], - }, - ); - const text = await response.text(); - - expect(text).toContain('"type":"server_tool_use"'); - expect(text).toContain('"name":"web_fetch"'); - expect(text).toContain('"type":"web_fetch_tool_result"'); - expect(text).toContain('"type":"web_fetch_result"'); - expect(text).toContain('"url":"https://page.test/article"'); - expect(text).toContain('Fetched article body.'); - expect(text.indexOf('"type":"server_tool_use"')).toBeLessThan( - text.indexOf('Article summary.'), - ); - }); - - it('keeps ordinary Messages replies live when WebSearch is available', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let releaseFinalChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - expect(String(input)).toContain('/v2/chat/completions'); - - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: { content: 'Live first chunk.' }, index: 0 }, - ], - })}\n\n`, - ), - ); - releaseFinalChunk = () => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { content: ' Final chunk.' }, - finish_reason: 'stop', - index: 0, - }, - ], - })}\n\ndata: [DONE]\n\n`, - ), - ); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Say hello' }], - stream: true, - tools: [ - { - description: 'Search the web', - input_schema: { type: 'object' }, - name: 'WebSearch', - }, - ], - }, - ); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let firstText = ''; - - while (!firstText.includes('Live first chunk.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - firstText += decoder.decode(chunk.value); - } - - expect(releaseFinalChunk).toBeTypeOf('function'); - releaseFinalChunk!(); - - let remainder = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - remainder += decoder.decode(chunk.value); - } - - expect(remainder).toContain('Final chunk.'); - }); - - it('keeps pre-tool Messages text live and executes a later CodeBuddy search', async () => { - await updateSettings({ - CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy', - CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy', - }); - const upstreamBodies: Array> = []; - let searchCalls = 0; - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - searchCalls++; - return makeJsonResponse({ - results: [ - { - snippet: 'Current iOS news.', - title: 'Latest iOS', - url: 'https://news.test/ios', - }, - ], - }); - } - - upstreamCalls++; - upstreamBodies.push( - JSON.parse(String(init?.body)) as Record, - ); - - return upstreamCalls === 1 - ? makeSseResponse( - { - choices: [ - { - delta: { content: 'I will search now.' }, - index: 0, - }, - ], - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"latest iOS news"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }, - ) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Here is the latest iOS news.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search for iOS news' }], - stream: true, - tools: [ - { - description: 'Search the web', - input_schema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'], - }, - name: 'WebSearch', - }, - ], - }, - ); - const text = await response.text(); - const firstTools = upstreamBodies[0]?.tools as Array<{ - function?: { name?: string }; - }>; - const secondMessages = upstreamBodies[1]?.messages as Array<{ - role?: string; - tool_call_id?: string; - }>; - - expect(text).toContain('I will search now.'); - expect(text).toContain('"type":"server_tool_use"'); - expect(text).toContain('"type":"web_search_tool_result"'); - expect(text).toContain('Here is the latest iOS news.'); - expect(text).not.toContain('"type":"tool_use"'); - expect(firstTools[0]?.function?.name).toBe('web_search'); - expect(secondMessages).toContainEqual( - expect.objectContaining({ role: 'tool', tool_call_id: 'call_search' }), - ); - expect(searchCalls).toBe(1); - expect(upstreamCalls).toBe(2); - }); - - it('keeps the text a multi-hop turn wrote between two searches', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_first', - index: 0, - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // The model speaks and reasons before searching again. That text is part - // of the visible turn, so it must not be swallowed by the loop. - if (upstreamCalls === 2) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'First hop was inconclusive.', - reasoning_content: 'Narrowing the query.', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Two hops later.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Two hop question' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const text = await response.text(); - - expect(text).toContain('First hop was inconclusive.'); - expect(text).toContain('Narrowing the query.'); - expect(text).toContain('Two hops later.'); - expect(text.indexOf('First hop was inconclusive.')).toBeLessThan( - text.indexOf('Two hops later.'), - ); - expect((text.match(/"type":"server_tool_use"/g) ?? []).length).toBe(2); - expect((text.match(/"type":"web_search_tool_result"/g) ?? []).length).toBe( - 2, - ); - // A buffered iteration is re-emitted from the payload, so it must appear - // exactly once — not once from the payload and once from the fold. - expect((text.match(/First hop was inconclusive\./g) ?? []).length).toBe(1); - expect((text.match(/Narrowing the query\./g) ?? []).length).toBe(1); - expect(upstreamCalls).toBe(3); - }); - - it('does not repeat text a streamed iteration already forwarded', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_first', - index: 0, - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - if (upstreamCalls === 2) { - // A streamed iteration: its deltas are forwarded as they arrive, so - // re-emitting the accumulated text afterwards would duplicate it. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: { content: 'Spoken between hops.' }, index: 0 }, - ], - })}\n\n`, - ), - ); - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { reasoning_content: 'Thinking between hops.' }, - index: 0, - }, - ], - })}\n\n`, - ), - ); - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_second', - index: 0, - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - ], - }, - index: 0, - }, - ], - })}\n\n`, - ), - ); - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: {}, finish_reason: 'tool_calls', index: 0 }, - ], - })}\n\n`, - ), - ); - controller.close(); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Two hops later.' } }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Two hop question' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const text = await response.text(); - - expect(text).toContain('Spoken between hops.'); - expect(text).toContain('Thinking between hops.'); - expect(text).toContain('Two hops later.'); - expect((text.match(/Spoken between hops\./g) ?? []).length).toBe(1); - expect((text.match(/Thinking between hops\./g) ?? []).length).toBe(1); - expect(upstreamCalls).toBe(3); - }); - - it('streams the final Messages answer as upstream produces it', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let upstreamCalls = 0; - let releaseSecondChunk: (() => void) | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"streamed answer"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // The final answer arrives in two pieces, the second only after the test - // releases it — so a buffered replay collapses both into one instant. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'First half.' }, index: 0 }], - })}\n\n`, - ), - ); - releaseSecondChunk = () => { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [ - { delta: { content: ' Second half.' }, index: 0 }, - ], - })}\n\n`, - ), - ); - controller.close(); - }; - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Stream the answer' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let before = ''; - - while (!before.includes('First half.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - before += decoder.decode(chunk.value); - } - - // The first half has to arrive before the second is even produced; a - // buffered final iteration would withhold it until the whole answer was in. - expect(before).not.toContain('Second half.'); - expect(releaseSecondChunk).toBeTypeOf('function'); - releaseSecondChunk!(); - - let remainder = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - remainder += decoder.decode(chunk.value); - } - - expect(remainder).toContain('Second half.'); - expect(before).toContain('"type":"web_search_tool_result"'); - }); - - it('streams Messages server_tool_use before the backend result', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"live messages"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'Messages answer.' }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search live' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let beforeResult = ''; - - while (!beforeResult.includes('"type":"server_tool_use"')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - beforeResult += decoder.decode(chunk.value); - } - - expect(finishSearch).toBeTypeOf('function'); - expect(beforeResult).not.toContain('"type":"web_search_tool_result"'); - finishSearch!( - makeJsonResponse({ - results: [ - { content: 'Live result', title: 'Live', url: 'https://live.test' }, - ], - }), - ); - - let afterResult = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - afterResult += decoder.decode(chunk.value); - } - - expect(afterResult).toContain('"type":"web_search_tool_result"'); - expect(afterResult).toContain('Messages answer.'); - }); - - it('streams a Messages error when local tool upstream execution fails', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - makeJsonResponse({ error: { message: 'upstream failed' } }, 502), - ); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search live' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - expect(await response.text()).toContain('"type":"error"'); - }); - - it.each([ - [ - 'an empty body', - new Response(null, { status: 502 }), - 'Upstream CodeBuddy request failed', - ], - ['a JSON string detail', makeJsonResponse({ detail: '123' }, 502), '123'], - ])( - 'maps %s from a non-streaming Messages failure', - async (_label, failure, message) => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(failure); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Fail normally' }], - }, - ); - const payload = (await response.json()) as { - error: { message: string }; - }; - - expect(response.status).toBe(502); - expect(payload.error.message).toBe(message); - }, - ); - - it('stops the follow-up iteration when the client disconnects mid-answer', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - const encoder = new TextEncoder(); - let upstreamCalls = 0; - let resolveCancel: (() => void) | undefined; - let upstreamCancelled: Promise | undefined; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"cancel mid answer"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - // The answer never finishes on its own, so only a cancellation can end - // this iteration. - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - choices: [{ delta: { content: 'Partial.' }, index: 0 }], - })}\n\n`, - ), - ); - upstreamCancelled = new Promise((resolve) => { - resolveCancel = () => resolve(true); - }); - }, - cancel: () => { - resolveCancel?.(); - }, - }), - { headers: { 'Content-Type': 'text/event-stream' } }, - ); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Cancel mid answer' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let seen = ''; - - while (!seen.includes('Partial.')) { - const chunk = await reader.read(); - expect(chunk.done).toBe(false); - seen += decoder.decode(chunk.value); - } - - await reader.cancel(); - - // The disconnect must reach the parked upstream read, not only the - // downstream stream, so a stalled upstream does not stay alive. - await expect(upstreamCancelled).resolves.toBe(true); - expect(upstreamCalls).toBe(2); - }); - - it('cancels a late Messages upstream stream after disconnect', async () => { - let finishSearch: ((response: Response) => void) | undefined; - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('searx.test')) { - return await new Promise((resolve) => { - finishSearch = resolve; - }); - } - - upstreamCalls++; - if (upstreamCalls === 1) { - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - function: { - arguments: '{"query":"cancel messages"}', - name: 'web_search', - }, - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Late answer.' } }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Search and disconnect' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - - await vi.waitFor(() => expect(finishSearch).toBeTypeOf('function')); - await response.body!.cancel(); - finishSearch!(makeJsonResponse({ results: [] })); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(upstreamCalls).toBe(1); - }); - - it('folds multi-hop text into a non-streaming Messages answer', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - if (upstreamCalls === 2) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'First hop was inconclusive.', - reasoning_content: 'Narrowing the query.', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - return makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Two hops later.' } }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Two hop question' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array<{ text?: string; thinking?: string; type: string }>; - }; - const text = payload.content - .filter((block) => block.type === 'text') - .map((block) => block.text ?? '') - .join(''); - const thinking = payload.content - .filter((block) => block.type === 'thinking') - .map((block) => block.thinking ?? '') - .join(''); - - // A non-streaming turn has no place to emit intermediate deltas, so the - // text is folded in ahead of the final answer rather than dropped. - expect(text).toContain('First hop was inconclusive.'); - expect(text).toContain('Two hops later.'); - expect(text.indexOf('First hop was inconclusive.')).toBeLessThan( - text.indexOf('Two hops later.'), - ); - expect(thinking).toContain('Narrowing the query.'); - expect(upstreamCalls).toBe(3); - }); - - it('does not repeat the current text in a non-streaming mixed turn', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - - if (upstreamCalls === 1) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"query":"first hop"}', - name: 'web_search', - }, - }, - ], - }, - }, - ], - }); - } - - // A turn that carries its own text, asks for another search, and also - // calls a client-owned tool: the mixed payload already includes the - // current text, so folding it in again would duplicate it. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking both.', - reasoning_content: 'Weighing the results.', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"query":"second hop"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Mixed turn' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array<{ text?: string; thinking?: string; type: string }>; - }; - const serialized = JSON.stringify(payload); - - expect((serialized.match(/Checking both\./g) ?? []).length).toBe(1); - expect((serialized.match(/Weighing the results\./g) ?? []).length).toBe(1); - expect(upstreamCalls).toBe(2); - }); - - it('does not fold findings into the text when a structured block carries them', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - // One turn mixing a local search with a client-owned call, so the loop - // cannot continue and has to hand the outstanding call back. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"two results"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Mixed turn' }], - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const payload = (await response.json()) as { - content: Array<{ text?: string; type: string }>; - }; - - // The result block is how this route reports the findings, so the prose - // must not repeat them: a second copy reads as the model reciting its own - // search output, and the "Cite the URL" line is an instruction to the - // model rather than something the user ever asked to see. - expect(upstreamCalls).toBeGreaterThan(0); - expect(payload.content.map((block) => block.type)).toContain( - 'web_search_tool_result', - ); - expect( - payload.content - .filter((block) => block.type === 'text') - .map((block) => block.text ?? '') - .join(''), - ).not.toContain('https://r.test'); - expect(JSON.stringify(payload)).not.toContain('Cite the URL'); - }); - - it('does not stream folded findings when a structured block carries them', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - // The very first upstream turn mixes the local search with a client - // call. That branch emits its own text delta rather than going through - // `buildMixedTurnPayload`, so it needs the same opt-out. - return makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_search', - index: 0, - function: { - arguments: '{"query":"two results"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - index: 1, - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - finish_reason: 'tool_calls', - index: 0, - }, - ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Mixed turn' }], - stream: true, - tools: [ - { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, - }, - ], - }, - ); - const text = await response.text(); - - // Structured result block present, findings not repeated as prose. - expect(text).toContain('"type":"web_search_tool_result"'); - - // `handleMessagesRequest` answers in Anthropic SSE, so the text lives in - // `content_block_delta` frames as `text_delta` — not in `choices`. - const contentDeltas = ( - await readSseEvents( - new Response(text, { - headers: { 'Content-Type': 'text/event-stream' }, - }), - ) - ) - .flatMap((payload) => { - try { - const parsed = JSON.parse(payload) as { - delta?: { text?: string; type?: string }; - }; - - return parsed.delta?.type === 'text_delta' && parsed.delta.text - ? [parsed.delta.text] - : []; - } catch { - return []; - } - }) - .join(''); - - expect(contentDeltas).not.toContain('https://r.test'); - expect(contentDeltas).not.toContain('Cite the URL'); - }); - - it('keeps folding findings for routes without a structured channel', async () => { - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); - - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/search')) { - return makeJsonResponse({ - results: [ - { snippet: 'snip', title: 'Result', url: 'https://r.test' }, - ], - }); - } - - upstreamCalls++; - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"two results"}', - name: 'web_search', - }, - }, - { - id: 'call_client', - function: { arguments: '{}', name: 'client_tool' }, - type: 'function', - }, - ], - }, - }, - ], - }); - }); - - const response = await proxyChatCompletions( - makeNextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - }), - { - messages: [{ content: 'Mixed turn', role: 'user' }], - model: 'glm-5.1', - tools: [{ type: 'web_search_preview' }], - } as never, - ); - const payload = (await response.json()) as { - choices: Array<{ message: { content: string | null } }>; - }; - - // /v1/chat/completions has no structured channel for the findings, so the - // fold has to stay: it is the only way the results reach the caller. - expect(upstreamCalls).toBeGreaterThan(0); - expect(payload.choices[0]?.message.content).toContain('https://r.test'); - }); - - it('maps a completed fetch to a Responses open_page call', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ - content: 'Fetched documentation.', - }); - } - - if (url.includes('docs.test')) { - throw new Error('The endpoint result should win the local fallback'); - } + // The client's own call survived, and it is the only `tool_use` here. + expect( + payload.content.filter((block) => block.type === 'tool_use'), + ).toEqual([expect.objectContaining({ name: 'WebSearch' })]); + expect(payload.stop_reason).toBe('tool_use'); + + // The turn stops at this hop: continuing would replay an assistant + // message whose client call has no result behind it. + expect(calls()).toBe(1); + }); + + /** + * §11 and §26: the query that reaches the provider has to be the one the + * model emitted as its `web_search` argument, never one extracted from the + * prompt. Every other phase-2 test uses the same string in both places, so + * a regression that regex-scraped the prompt would pass them all. + */ + it('searches for the query the model emitted, not the one in the prompt', async () => { + await enableSearch(); + const { run } = routed( + [searchCall('IBM quantum 2026', 'call_1'), answer('Here it is.')], + sideRequest(8), + ); - upstreamCalls++; - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://docs.test/start"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Documentation.' } }, - ], - }); - }); + const response = await run(); + const payload = (await response.json()) as { + content: Array<{ input?: { query?: string }; type: string }>; + usage: { server_tool_use: { web_search_requests: number } }; + }; - const response = await handleResponsesRequest( - makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), - { - input: 'Read https://docs.test/start', - tools: [{ type: 'web_fetch_20250910', name: 'web_fetch' }], - }, - ); - const payload = (await response.json()) as { - output: Array>; - }; + const searchedQueries = providerQueries(); - expect(payload.output[0]).toMatchObject({ - type: 'web_search_call', - status: 'completed', - action: { type: 'open_page', url: 'https://docs.test/start' }, + // The prompt asked about OpenAI; the model chose IBM. Only the model's + // argument may reach the provider. + expect(searchedQueries).toEqual(['IBM quantum 2026']); + expect(payload.usage.server_tool_use.web_search_requests).toBe(1); + expect(payload.content[0]).toMatchObject({ + input: { query: 'IBM quantum 2026' }, + type: 'server_tool_use', + }); }); - expect(payload.output[1]).toMatchObject({ type: 'message' }); }); - it('includes completed search blocks in a non-streaming Messages response', async () => { - let upstreamCalls = 0; + /** + * Phase 3: Claude Code wraps the side-request answer as a `tool_result` for + * its own `WebSearch` and carries on. This must not re-enter the server-tool + * path — and it is the shape the whole flow exists to serve. + */ + it('serves the main agent continuation without searching again', async () => { + await enableSearch(); + let calls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = String(input); if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [ - { - content: 'Search snippet', - }, - ], - }); + throw new Error('the continuation must not search'); } - upstreamCalls++; - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ + calls += 1; + + return makeJsonResponse({ + choices: [ + { + finish_reason: 'stop', + message: { content: 'OpenAI 最近主要有这些更新。' }, + }, + ], + }) as unknown as Response; + }); + + const response = await handleMessagesRequest( + makeRequest('http://localhost/v1/messages'), + { + max_tokens: 2048, + messages: [ + { role: 'user', content: '帮我查一下 OpenAI 最近有什么更新' }, + { + role: 'assistant', + content: [ { - finish_reason: 'tool_calls', - message: { - tool_calls: [ - { - id: 'call_search', - function: { - arguments: '{"query":"current status"}', - name: 'web_search', - }, - }, - ], - }, + type: 'tool_use', + id: 'toolu_search_001', + name: 'WebSearch', + input: { query: 'OpenAI latest updates 2026' }, }, ], - }) - : makeJsonResponse({ - choices: [ + }, + { + role: 'user', + content: [ { - finish_reason: 'stop', - message: { content: 'Current answer.' }, + type: 'tool_result', + tool_use_id: 'toolu_search_001', + content: '根据搜索结果,OpenAI 最近……', }, ], - }); - }); - - const response = await handleMessagesRequest( - makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'What is current?' }], + }, + ], tools: [ { - type: 'web_search_20260209', - name: 'web_search', - input_schema: {}, + name: 'WebSearch', + description: 'Search the web', + input_schema: { type: 'object' }, + }, + { + name: 'Read', + description: 'read', + input_schema: { type: 'object' }, }, ], }, ); + const payload = (await response.json()) as { - content: Array>; - usage: Record; + content: Array<{ type: string }>; + stop_reason: string; }; - expect(payload.content[0]).toMatchObject({ - type: 'server_tool_use', - name: 'web_search', - input: { query: 'current status' }, - }); - expect(payload.content[1]).toMatchObject({ - type: 'web_search_tool_result', - content: [ - { - type: 'web_search_result', - title: '', - url: '', - }, - ], - }); - expect(payload.content[2]).toMatchObject({ - type: 'text', - text: 'Current answer.', - }); - expect(payload.usage.server_tool_use).toEqual({ - web_search_requests: 1, - web_fetch_requests: 0, - }); + expect(calls).toBe(1); + expect(payload.content).toEqual([ + { type: 'text', text: 'OpenAI 最近主要有这些更新。' }, + ]); + expect(payload.stop_reason).toBe('end_turn'); }); -}); - -describe('proxy integration', () => { - const tempRootDir = path.join( - process.cwd(), - '.tmp-test-websearch-proxy-root', - ); - const tempDataDir = path.join(tempRootDir, '.codebuddy_data'); - const makeProxyRequest = () => - new NextRequest('http://localhost/v1/chat/completions', { - method: 'POST', - headers: { authorization: 'Bearer test-token' }, - }); + describe('/v1/responses', () => { + it('reports the search as a web_search_call item', async () => { + await enableSearch(); + mockUpstream(); - beforeEach(async () => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - resetCredentialRuntimeState(); - cleanupDir(); - fs.mkdirSync(tempDataDir, { recursive: true }); - vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - process.env.CODEBUDDY_AUTH_MODE = 'auto'; - await addCredential({ - bearer_token: 'websearch-proxy-token', - responses_passthrough: false, - user_id: 'websearch@example.com', - }); - }); + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'when did it ship?', + model: 'glm-5.1', + tools: [{ type: 'web_search_preview' }], + }, + ); - afterEach(() => { - for (const name of SEARXNG_ENV_NAMES) { - delete process.env[name]; - } - resetWebSearchProviders(); - cleanupDir(); - vi.restoreAllMocks(); - }); + const payload = (await response.json()) as { + output: Array>; + }; + const types = payload.output.map((item) => item.type); - const cleanupDir = (): void => { - fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); - }; + expect(types).toContain('web_search_call'); + expect(types).toContain('message'); + // The search ran before the answer that used it. + expect(types.indexOf('web_search_call')).toBeLessThan( + types.indexOf('message'), + ); + expect(payload.output[0]).toMatchObject({ + action: { query: 'latest release', type: 'search' }, + status: 'completed', + }); + }); - it('runs a local search end to end for a non-streaming request', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + /** + * The preamble is the prose a model writes before it searches. It was + * repositioned three times and had no test at all: this pins both halves — + * that it is emitted, and that it lands *before* the searches it preceded. + */ + it('puts a Responses preamble ahead of the searches, and streams the answer', async () => { + await enableSearch(); + let calls = 0; - const fetchMock = vi.spyOn(globalThis, 'fetch'); - let upstreamCalls = 0; - fetchMock.mockImplementation(async (input) => { - const url = String(input); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); - if (url.includes('searx.test')) { - return makeJsonResponse({ - results: [ - { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, - ], - }); - } + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } - upstreamCalls += 1; + calls += 1; - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: null, - role: 'assistant', - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - arguments: '{"query":"latest release"}', - name: 'web_search', - }, + return makeJsonResponse( + calls === 1 + ? { + choices: [ + { + finish_reason: 'tool_calls', + message: { + // Speaks first, then asks — the case the preamble is for. + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates"}', + name: 'web_search', + }, + }, + ], }, - ], - }, - }, - ], - usage: { total_tokens: 30 }, - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { content: 'It shipped yesterday.' }, + }, + ], + } + : { + choices: [ + { + finish_reason: 'stop', + message: { + content: 'Here is what I found.', + role: 'assistant', + }, + }, + ], }, - ], - usage: { total_tokens: 40 }, - }); - }); + ) as unknown as Response; + }); - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'when did it ship?' }], - tools: [{ type: 'web_search_20260209', name: 'web_search' }], - }); + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'any news on OpenAI?', + model: 'glm-5.1', + tools: [{ type: 'web_search_preview' }], + }, + ); - expect(response.ok).toBe(true); - const payload = (await response.json()) as { - choices: Array<{ message: { content: string | null } }>; - usage?: { total_tokens?: number }; - }; - expect(payload.choices[0]?.message.content).toBe('It shipped yesterday.'); - // Usage from both iterations is summed. - expect(payload.usage?.total_tokens).toBe(70); - expect(upstreamCalls).toBe(2); - }); + const payload = (await response.json()) as { + output: Array<{ content?: Array<{ text?: string }>; type: string }>; + output_text: string; + }; - it('serves a synthesized stream when a search request asks to stream', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + expect(payload.output.map((item) => item.type)).toEqual([ + 'reasoning', + 'message', + 'web_search_call', + 'message', + ]); + // The preamble, then the search, then the answer — in the order written. + expect(payload.output[1].content?.[0]?.text).toBe('Let me look that up.'); + expect(payload.output[3].content?.[0]?.text).toBe( + 'Here is what I found.', + ); + // Not the preamble: a delta-subscribing client must get the answer. + expect(payload.output_text).toBe('Here is what I found.'); + }); - const fetchMock = vi.spyOn(globalThis, 'fetch'); - let upstreamCalls = 0; - fetchMock.mockImplementation(async (input) => { - const url = String(input); + it('streams a Responses preamble ahead of the searches', async () => { + await enableSearch(); + let calls = 0; - if (url.includes('searx.test')) { - return makeJsonResponse({ results: [] }); - } + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } - upstreamCalls += 1; + calls += 1; - return upstreamCalls === 1 - ? makeSseResponse({ - choices: [ - { - delta: { - tool_calls: [ - { - id: 'call_1', - index: 0, - function: { - arguments: '{"query":"weather"}', - name: 'web_search', - }, + return makeJsonResponse( + calls === 1 + ? { + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Let me look that up.', + reasoning_content: 'The user wants recent news.', + role: 'assistant', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + arguments: '{"query":"OpenAI updates"}', + name: 'web_search', + }, + }, + ], }, - ], - }, - finish_reason: 'tool_calls', - index: 0, + }, + ], + } + : { + choices: [ + { + finish_reason: 'stop', + message: { + content: 'Here is what I found.', + role: 'assistant', + }, + }, + ], }, - ], - }) - : makeJsonResponse({ - choices: [ - { finish_reason: 'stop', message: { content: 'Sunny today.' } }, - ], - }); + ) as unknown as Response; + }); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'any news on OpenAI?', + model: 'glm-5.1', + stream: true, + tools: [{ type: 'web_search_preview' }], + }, + ); + + const text = await response.text(); + + // One opening, under one id. + expect(text.match(/"type":"response\.created"/g)).toHaveLength(1); + expect(text).toContain('Let me look that up.'); + expect(text).toContain('Here is what I found.'); + // The answer is what streams as text deltas, not the preamble. + expect(text).toContain('"delta":"Here is what I found."'); + expect(text).toContain('"type":"response.completed"'); }); - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'weather?' }], - stream: true, - tools: [{ type: 'web_search_preview' }], + it('reports the upstream’s own message when a streamed turn fails', async () => { + await enableSearch(); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('searx.test')) { + return makeJsonResponse({ results: [] }) as unknown as Response; + } + + return new Response( + JSON.stringify({ error: { message: 'rate limited upstream' } }), + { headers: { 'Content-Type': 'application/json' }, status: 429 }, + ) as unknown as Response; + }); + + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'any news?', + model: 'glm-5.1', + stream: true, + tools: [{ type: 'web_search_preview' }], + }, + ); + + const text = await response.text(); + + // A rate limit has to arrive as one, or a client that retries on that + // alone stops retrying. + expect(text).toContain('rate limited upstream'); + expect(text).toContain('"type":"response.error"'); }); - expect(response.headers.get('content-type')).toContain('text/event-stream'); - const text = await response.text(); - expect(text).toContain('Sunny today.'); - expect(text).toContain('data: [DONE]'); - }); + it('leaves a client function named web_search to the client', async () => { + await enableSearch(); + const { upstreamCalls } = mockUpstream(); - it('interleaves thinking and text around each fetch in a non-streaming reply', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'read the file', + model: 'glm-5.1', + tools: [{ type: 'function', name: 'web_search', parameters: {} }], + }, + ); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); + const payload = (await response.json()) as { + output: Array>; + }; - if (url.includes('/agenttool/v1/webfetch')) { - // Backends report the URL they actually read, which follows redirects - // and so can differ from the one the model asked for. - return makeJsonResponse({ - content: 'Fetched body.', - url: 'https://page.test/a?redirected=1', - }); - } + expect(payload.output.map((item) => item.type)).not.toContain( + 'web_search_call', + ); + expect(upstreamCalls()).toBe(1); + }); - upstreamCalls += 1; + it('streams the search lifecycle events', async () => { + await enableSearch(); + mockUpstream(); - // The model thinks, speaks, then fetches — twice over. Anthropic lays a - // turn out as thinking → text → tool_use → tool_result → thinking → - // text, so each hop's reasoning stays attached to the text it justifies. - if (upstreamCalls === 1) { - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Looking it up.', - reasoning_content: 'I should check the page.', - role: 'assistant', - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }); - } + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + { + input: 'when did it ship?', + model: 'glm-5.1', + stream: true, + tools: [{ type: 'web_search_preview' }], + }, + ); - return makeJsonResponse({ - choices: [ - { - finish_reason: 'stop', - message: { - content: 'Here is what it said.', - reasoning_content: 'The page confirms it.', - }, - }, - ], - }); - }); + const events = await readEvents(response); + const types = events.map((event) => event.event); - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read https://page.test/a' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - ], - }, - ); + expect(types).toContain('response.output_item.added'); + expect(types).toContain('response.web_search_call.in_progress'); + expect(types).toContain('response.web_search_call.searching'); + expect(types).toContain('response.web_search_call.completed'); + expect(types).toContain('response.output_item.done'); + }); - const payload = (await response.json()) as { - content: Array<{ - thinking?: string; - text?: string; - type: string; - content?: { url?: string }; - }>; - }; - const blocks = payload.content.map((block) => - block.type === 'thinking' - ? `thinking:${block.thinking}` - : block.type === 'text' - ? `text:${block.text}` - : block.type, - ); + /** + * Answers each hop from `hops`, falling through on the last, and keeps + * every request body that reached upstream. + * + * The Responses path is non-streaming hop to hop regardless of `stream`: + * whether the model wants another search is only knowable once a hop has + * finished, so the turn buffers every one. + */ + const routed = ( + hops: Array>, + request: Record, + ) => { + const sent: Array> = []; + let calls = 0; - // Each hop keeps its own reasoning ahead of its own text, and the fetch - // sits between the two hops rather than ahead of both. - expect(blocks).toEqual([ - 'thinking:I should check the page.', - 'text:Looking it up.', - 'server_tool_use', - 'web_fetch_tool_result', - 'thinking:The page confirms it.', - 'text:Here is what it said.', - ]); - // The result carries the URL the backend read, not the one requested. - expect(payload.content[3]?.content?.url).toBe( - 'https://page.test/a?redirected=1', - ); - expect(upstreamCalls).toBe(2); - }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + const url = String(_input); - it('keeps the tool blocks first when a hop calls a tool without speaking', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; + if (url.includes('searx.test')) { + return makeJsonResponse({ + results: [ + { content: 'A snippet', title: 'Docs', url: 'https://docs.test' }, + ], + }) as unknown as Response; + } - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); + const body = JSON.parse(String(init?.body)) as Record; + sent.push(body); + const hop = hops[Math.min(calls, hops.length - 1)]; + calls += 1; - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } + return makeJsonResponse(hop) as unknown as Response; + }); - upstreamCalls += 1; + return { + calls: () => calls, + run: () => + handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + request, + ), + sent, + }; + }; - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ + const searchHop = (query: string) => ({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + role: 'assistant', + tool_calls: [ { - finish_reason: 'tool_calls', - message: { - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], + id: 'call_1', + type: 'function', + function: { + arguments: `{"query":"${query}"}`, + name: 'web_search', }, }, ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Done.' } }], - }); + }, + }, + ], + usage: { completion_tokens: 10, prompt_tokens: 100 }, }); - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read https://page.test/a' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - ], - }, - ); + const answerHop = (text: string) => ({ + choices: [ + { + finish_reason: 'stop', + message: { content: text, role: 'assistant' }, + }, + ], + usage: { completion_tokens: 20, prompt_tokens: 200 }, + }); - const payload = (await response.json()) as { - content: Array<{ text?: string; type: string }>; + /** A search declared and pinned by its hosted-tool type. */ + const pinned = { + input: 'any news?', + model: 'glm-5.1', + tool_choice: { type: 'web_search_preview' }, + tools: [{ type: 'web_search_preview' }], }; - // The model went straight to the tool, so there is no prose to put first: - // the fetch opens the turn and the answer closes it. - expect( - payload.content.map((block) => - block.type === 'text' ? `text:${block.text}` : block.type, - ), - ).toEqual(['server_tool_use', 'web_fetch_tool_result', 'text:Done.']); - }); - - it('keeps earlier hops grouped when a later hop mixes in a client tool', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; + /** + * The Responses API pins a hosted tool by its declared type, and the pin is + * load-bearing: it is what makes the model emit a query instead of + * answering from memory. It used to be rejected outright — 400, no search. + */ + it('runs the search when tool_choice pins the hosted tool', async () => { + await enableSearch(); + mockUpstream(); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); + const response = await handleResponsesRequest( + makeRequest('http://localhost/v1/responses'), + pinned, + ); - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } + expect(response.status).toBe(200); - upstreamCalls += 1; + const payload = (await response.json()) as { + output: Array>; + }; + const types = payload.output.map((item) => item.type); - // The first hop searches on its own; the second runs a server tool and - // also asks the client for one of its own. The loop has to hand the - // client's call back, and the hop metadata still has to reach the - // block renderer — losing it flattens every hop's prose ahead of the - // tool blocks, which is the bug this guards. - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Checking first.', - role: 'assistant', - tool_calls: [ - { - id: 'call_first', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Now yours.', - role: 'assistant', - tool_calls: [ - { - id: 'call_second', - function: { - arguments: '{"url":"https://page.test/b"}', - name: 'web_fetch', - }, - }, - { - id: 'call_client', - function: { - arguments: '{"city":"Berlin"}', - name: 'weather', - }, - }, - ], - }, - }, - ], - }); + expect(types).toContain('web_search_call'); + expect(types).toContain('message'); + // The pin held, so the search ran before the answer that used it. + expect(types.indexOf('web_search_call')).toBeLessThan( + types.indexOf('message'), + ); }); - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read both' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - { name: 'weather', input_schema: {}, type: 'custom' }, - ], - }, - ); + it('sends the pin upstream as the function the proxy injected', async () => { + await enableSearch(); + const { run, sent } = routed( + [searchHop('OpenAI updates'), answerHop('Here it is.')], + pinned, + ); - const payload = (await response.json()) as { - content: Array<{ text?: string; type: string }>; - }; + await run(); - // The first hop stays ahead of the second hop's fetch instead of both - // fetches collapsing to the end, and the client's own call survives as a - // tool_use the client has to resolve. - expect( - payload.content.map((block) => - block.type === 'text' ? `text:${block.text}` : block.type, - ), - ).toEqual([ - 'text:Checking first.', - 'server_tool_use', - 'web_fetch_tool_result', - 'text:Now yours.', - 'server_tool_use', - 'web_fetch_tool_result', - 'tool_use', - ]); - }); + // Upstream has never heard of `web_search_preview`; the pin has to name + // the function the declaration was rewritten into. + expect(sent[0]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'web_search' }, + }); + }); - it('keeps hop metadata off the OpenAI chat-completions response', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); - let upstreamCalls = 0; + it('stops pinning the hosted tool after the first hop', async () => { + await enableSearch(); + const { run, sent } = routed( + [searchHop('OpenAI updates'), answerHop('Here it is.')], + pinned, + ); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); + await run(); - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } + // Left pinned, the model would be forced to search forever instead of + // answering with what it found. + expect(sent[1]?.tool_choice).toBe('auto'); + }); - upstreamCalls += 1; + it('drops the pin instead of failing when no backend is configured', async () => { + // No `enableSearch()`: `beforeEach` clears SEARXNG_URL, so nothing here + // can run the declared tool and it is withdrawn from the request. + const { run, sent } = routed( + [answerHop('It shipped in March, as I recall.')], + pinned, + ); - return upstreamCalls === 1 - ? makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Looking it up.', - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - ], - }, - }, - ], - }) - : makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'Done.' } }], - }); - }); + const response = await run(); - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'Read https://page.test/a' }], - tools: [ - { name: 'web_fetch', type: 'function' }, - { type: 'web_fetch_20260209', name: 'web_fetch' }, - ], - }); + expect(response.status).toBe(200); + // A choice naming a tool the request no longer offers is a contradiction + // upstream rejects, so it goes rather than being sent as it is. + expect(sent[0]?.tool_choice).toBeUndefined(); - const payload = (await response.json()) as Record; - - // The per-hop grouping is not part of the OpenAI protocol. Serializing it - // here would hand chat clients a field that names internal tool inputs and - // results, which strict validators reject and every other client receives - // as duplicated tool data. - expect(Object.keys(payload)).not.toContain('turns'); - expect(JSON.stringify(payload)).not.toContain('web_fetch_tool_result'); - // Guards against the assertion passing because the loop never ran: a - // two-hop turn is what would have carried the grouping in the first place. - expect(upstreamCalls).toBe(2); - }); + const payload = (await response.json()) as { + output: Array>; + output_text: string; + }; - it('keeps the prose of a first hop that mixes in a client tool', async () => { - await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); + expect(payload.output.map((item) => item.type)).not.toContain( + 'web_search_call', + ); + // Answering from memory is the honest degradation, not a 400. + expect(payload.output_text).toBe('It shipped in March, as I recall.'); + }); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); + const pinnedImage = { + input: 'draw a cat', + model: 'glm-5.1', + tool_choice: { type: 'image_generation' }, + tools: [{ type: 'image_generation' }], + }; - if (url.includes('/agenttool/v1/webfetch')) { - return makeJsonResponse({ content: 'Fetched body.' }); - } + /** + * Image generation is executed here, by its own loop rather than the + * server-tool turn, so a pin on it is a request this adapter can serve — + * and it used to be rejected outright with a 400. + */ + it('serves a request pinning the image tool by its hosted type', async () => { + const { run, sent } = routed( + [answerHop('Here is the cat.')], + pinnedImage, + ); - // The very first response already carries both a locally executed tool - // and a client-owned one. There is no earlier hop to fold, so the hop - // metadata has to be built from this iteration alone. - return makeJsonResponse({ - choices: [ - { - finish_reason: 'tool_calls', - message: { - content: 'Let me check, then you decide.', - reasoning_content: 'I need the page first.', - role: 'assistant', - tool_calls: [ - { - id: 'call_fetch', - function: { - arguments: '{"url":"https://page.test/a"}', - name: 'web_fetch', - }, - }, - { - id: 'call_client', - function: { - arguments: '{"city":"Berlin"}', - name: 'weather', - }, - }, - ], - }, - }, - ], + const response = await run(); + + expect(response.status).toBe(200); + // Upstream has never heard of `image_generation` as a tool type; the pin + // has to name the function the declaration was rewritten into. + expect(sent[0]?.tool_choice).toEqual({ + type: 'function', + function: { name: 'image_generation' }, }); }); - const response = await handleMessagesRequest( - new NextRequest('http://localhost/v1/messages', { method: 'POST' }), - { - max_tokens: 1024, - messages: [{ role: 'user', content: 'Read it' }], - tools: [ - { type: 'web_fetch_20260209', name: 'web_fetch', input_schema: {} }, - { name: 'weather', input_schema: {}, type: 'custom' }, - ], - }, - ); - - const payload = (await response.json()) as { - content: Array<{ text?: string; thinking?: string; type: string }>; - }; + /** + * `file_search` has no implementation here, so its declaration is + * withdrawn from what goes upstream. Pinning it is therefore not a client + * error but a request to serve without the tool — the same degradation a + * server tool with no backend already gets. + */ + it('drops a pin on a hosted tool this adapter does not implement', async () => { + const { run, sent } = routed([answerHop('From memory.')], { + input: 'search the docs', + model: 'glm-5.1', + tool_choice: { type: 'file_search' }, + tools: [{ type: 'file_search' }], + }); - // A block renderer renders purely from the hop metadata once it is - // non-empty, so this hop's prose has to be on the turn: leaving it off - // drops everything the model said here, not just reorders it. - expect( - payload.content.map((block) => - block.type === 'thinking' - ? `thinking:${block.thinking}` - : block.type === 'text' - ? `text:${block.text}` - : block.type, - ), - ).toEqual([ - 'thinking:I need the page first.', - 'text:Let me check, then you decide.', - 'server_tool_use', - 'web_fetch_tool_result', - 'tool_use', - ]); - }); + const response = await run(); - it('passes through untouched when no search tool is declared', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + expect(response.status).toBe(200); + expect(sent[0]?.tool_choice).toBeUndefined(); - const fetchMock = vi.spyOn(globalThis, 'fetch'); - fetchMock.mockImplementation(async () => - makeJsonResponse({ - choices: [{ finish_reason: 'stop', message: { content: 'plain' } }], - }), - ); + const payload = (await response.json()) as { output_text: string }; - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'hello' }], + // The request is still served — just without the tool it pinned. + expect(payload.output_text).toBe('From memory.'); }); - expect(response.ok).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); + /** + * The relaxation above is scoped to a declaration the request actually + * made. A pin naming a tool that was never on offer is a client error, and + * it has to stay one. + */ + it('rejects a pin on a hosted tool the request never declared', async () => { + const { run, sent } = routed([answerHop('From memory.')], { + input: 'search the docs', + model: 'glm-5.1', + tool_choice: { type: 'file_search' }, + tools: [{ type: 'function', name: 'lookup', parameters: {} }], + }); + + const response = await run(); + + expect(response.status).toBe(400); + // Rejected before anything is sent upstream. + expect(sent).toHaveLength(0); + }); }); - it('returns the upstream failure when the search request errors', async () => { - process.env.SEARXNG_URL = 'https://searx.test'; - resetWebSearchProviders(); - await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng' }); + describe('/v1/chat/completions', () => { + /** + * A chat client's `web_search` function is its own. There is no + * server-tool convention in the chat protocol, so nothing here runs — + * the call goes back for the client to resolve. + */ + it('does not execute a client’s own web_search function', async () => { + await enableSearch(); + const { upstreamCalls } = mockUpstream(); - const fetchMock = vi.spyOn(globalThis, 'fetch'); - fetchMock.mockImplementation(async () => - makeJsonResponse({ error: { message: 'upstream down' } }, 502), - ); + const response = await proxyChatCompletions( + makeRequest('http://localhost/v1/chat/completions'), + { + messages: [{ role: 'user', content: 'search for it' }], + tools: [{ type: 'function', function: { name: 'web_search' } }], + }, + ); - const response = await proxyChatCompletions(makeProxyRequest(), { - messages: [{ role: 'user', content: 'hello' }], - tools: [{ type: 'web_search_preview' }], - }); + const payload = (await response.json()) as { + choices: Array<{ message: { tool_calls?: unknown[] } }>; + }; - expect(response.status).toBe(502); + expect(payload.choices[0].message.tool_calls).toHaveLength(1); + expect(upstreamCalls()).toBe(1); + }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 6d56f34..e14932f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,6 +33,10 @@ const vitestConfig = defineConfig({ 'dist/**', 'e2e/**', 'node_modules/**', + // A git worktree is a full checkout, so it carries its own copy of the + // suite — and, once built, a second copy under `.next/standalone`. + // Collecting those runs stale tests against the current tree. + '.worktrees/**', ], coverage: { provider: 'v8',