From a2d2b7866f4d477e3bda0127d3937bcf312f12d4 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 06:03:48 -0700 Subject: [PATCH] Studio: wire Anthropic web_fetch server-side tool (#5671) * Studio: wire Anthropic web_fetch server-side tool Studio's Anthropic passthrough only forwarded web_search and code_execution when enabled_tools was set. Asking Claude through Studio to fetch a URL produced no fetch (the tool was not in the outbound tools array), so users had to fall back to web_search even when they already had the exact URL they wanted. This change opts in web_fetch_20250910 when enabled_tools contains "web_fetch". The new tool entry is appended alongside any existing web_search / code_execution entries: {"type": "web_fetch_20250910", "name": "web_fetch", "max_uses": 5} No anthropic-beta header is required (web_fetch is GA); the existing code-execution-2025-08-25 flag continues to merge cleanly when both tools are enabled in the same turn. SSE translation mirrors the web_search path. A `server_tool_use` block with name="web_fetch" emits a `tool_start` _toolEvent carrying the URL the model asked to fetch; the matching `web_fetch_tool_result` block emits a `tool_end` _toolEvent whose result string follows the Title / URL / Snippet shape parseSourcesFromResult on the frontend already expects, so the source pill renders identically. Error blocks (`web_fetch_tool_error`) are surfaced as "Error: " matching the code_execution error path. The final "Anthropic stream complete" log line picks up web_fetch_ requested / web_fetch_invocations / web_fetch_urls so support reports of "the model did not fetch anything" can be triaged from the log. Verified end to end against claude-haiku-4-5 with `enabled_tools=["web_fetch"]`: the model emitted tool_start with url=https://example.com and tool_end with the page Title + URL + Snippet, plus the assistant message correctly read back "Example Domain" as the title. Tests: - 5 new unit tests in test_anthropic_web_fetch.py covering tool registration, the combined web_search + web_fetch + code_execution request body, the pill-off case, and SSE translation for both success and error paths. - All 242 existing Anthropic + OpenAI provider tests still pass. The enabled_tools field description in models/inference.py is updated so OpenAPI consumers see the new option. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * web_fetch: title fallback to URL, log parse failures, drop dead checks Three review nits on the previous commit: 1. `_format_web_fetch_result` left `title` empty when Anthropic omitted `document.title`. The frontend `parseSourcesFromResult` only emits a source pill when both `Title:` and `URL:` lines are present, so fetches against pages without an HTML title tag silently lost their citation in the UI. Fall back to `title = title or url`, matching the web_search formatter. 2. The broad `except Exception` around `json.loads(buffer)` for the web_fetch input swallowed the failure with no trace. Log at debug so a malformed partial_json buffer can be triaged from the server log without changing behavior. 3. `inner` was already sanitised to a dict at the matching content_block_start and `_format_web_fetch_result` always returns a non-empty string (defaulting to "(fetch complete)"), so the `isinstance(inner, dict) else {}` guard and the `result_text or "(fetch complete)"` fallback at the emit site were dead code. Removed. Added a test exercising the titleless path so the fallback stays covered. * chat-adapter: emit source pills for web_fetch tool calls `parseSourcesFromResult` was only wired up for tool calls where `toolName === "web_search"`, so the Title / URL / Snippet block the backend formatter emits for `web_fetch_tool_result` never reached the source-pill renderer. Users saw the raw tool result in the tool card but the dedicated source-pill row at the message tail stayed empty. Both web_search and web_fetch ship the same text shape today, so the fix is to broaden the gate. * Address review: wire web_fetch from Search pill + fix pause_turn truncation Two reviewer follow-ups on the Anthropic web_fetch PR: 1. The backend tool wiring landed but the frontend chat-adapter never put `web_fetch` in `enabled_tools`, so toggling the Search pill only ever attached `web_search` -- web_fetch was unreachable from the UI. Added providerSupportsBuiltinWebFetch() (Anthropic today) and paired the entry with the existing Search pill, since the canonical workflow is "search returns URLs, fetch reads them" and there is no separate UI toggle yet. 2. `pause_turn` from Anthropic's stop_reason vocabulary fell through the finish_reason map's "stop" default, which the OpenAI-format client renders as end-of-message and truncates the answer. Per the docs pause_turn means "Claude paused a long server-tool turn (web_search / web_fetch) and will resume". Mapped to None and skipped the chunk emission so the SSE stream still ends with [DONE] on message_stop but no terminal finish_reason lands on the client. While there: added explicit mappings for `tool_use` (-> tool_calls) and `refusal` (-> content_filter) which were also falling through to "stop". Tests added: pause_turn emits no finish_reason, end_turn still emits "stop", refusal maps to "content_filter". Sourcing: https://platform.claude.com/docs/en/api/messages#response-stop-reason --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../core/inference/external_provider.py | 229 ++++++- studio/backend/models/inference.py | 8 +- .../backend/tests/test_anthropic_web_fetch.py | 586 ++++++++++++++++++ .../src/features/chat/api/chat-adapter.ts | 26 +- .../features/chat/provider-capabilities.ts | 14 + 5 files changed, 845 insertions(+), 18 deletions(-) create mode 100644 studio/backend/tests/test_anthropic_web_fetch.py diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index b9e224bd66..71a5293f7e 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -1378,6 +1378,31 @@ class ExternalProviderClient: ) body["tools"] = anthropic_tools + # Anthropic server-side web_fetch — see + # https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-fetch-tool + # `web_fetch_20250910` reads a single URL (text or PDF) and + # returns a document block in a `web_fetch_tool_result`. For + # safety Anthropic only lets the model fetch URLs that already + # appeared in the conversation (user message, prior tool + # result, web_search hit) — there is no domain restriction we + # have to apply locally. No beta header is required today; the + # tool ships under the standard `2023-06-01` API version. We + # mirror the web_search wiring: max_uses cap, opt in via + # `enabled_tools=["web_fetch"]`, citations off by default + # because the frontend already paints source pills from the + # generic tool_end payload. + web_fetch_enabled = bool(enabled_tools and "web_fetch" in enabled_tools) + if web_fetch_enabled: + anthropic_tools = list(body.get("tools") or []) + anthropic_tools.append( + { + "type": "web_fetch_20250910", + "name": "web_fetch", + "max_uses": 5, + } + ) + body["tools"] = anthropic_tools + # Anthropic server-side code execution — see # https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool # The tool type is date-pinned per model family. @@ -1442,10 +1467,23 @@ class ExternalProviderClient: body.get("max_tokens"), ) - _finish_reason_map = { + # Translate Anthropic stop reasons onto the OpenAI chat-completions + # `finish_reason` vocabulary. `pause_turn` maps to None so the + # adapter does NOT emit a finish_reason chunk: pause_turn means + # Claude paused a long server-tool turn (web_search / web_fetch) + # and will continue once the user (or our retry) sends back the + # partial assistant message. Forwarding it as "stop" makes the + # OpenAI client think the answer is done and truncates the + # rendered message. `refusal` maps to "content_filter" as the + # nearest semantic match. See + # https://platform.claude.com/docs/en/api/messages#response-stop-reason + _finish_reason_map: dict[str, Optional[str]] = { "end_turn": "stop", "max_tokens": "length", "stop_sequence": "stop", + "tool_use": "tool_calls", + "refusal": "content_filter", + "pause_turn": None, } logger.info("Proxying Anthropic Messages API to %s (model=%s)", url, model) @@ -1541,6 +1579,16 @@ class ExternalProviderClient: current_code_exec_use: Optional[dict[str, Any]] = None current_code_exec_result: Optional[dict[str, Any]] = None code_execution_calls: dict[str, dict[str, Any]] = {} + # web_fetch state. Same server_tool_use → *_tool_result + # block shape as web_search but the server_tool_use + # carries name="web_fetch" and the result block is + # `web_fetch_tool_result` with content.type= + # `web_fetch_result` (success) or `web_fetch_tool_error` + # (failure). Kept separate from web_search state so a + # turn that uses both does not collide. + current_web_fetch_use: Optional[dict[str, Any]] = None + current_web_fetch_result: Optional[dict[str, Any]] = None + web_fetch_calls: dict[str, dict[str, Any]] = {} # Counts surfaced in the final log line so reports of # "Code execution did nothing" can be triaged at a # glance. generated_files_count is interesting for the @@ -1610,6 +1658,60 @@ class ExternalProviderClient: blocks.append(f"Title: {title}\nURL: {url}") return "\n---\n".join(blocks) + def _format_web_fetch_result(inner: dict[str, Any]) -> str: + """Render a `web_fetch_tool_result.content` payload + as the Title / URL / snippet block CodeExecutionToolUI + and parseSourcesFromResult already expect from the + web_search path. + + Success shape (text): + {type: web_fetch_result, url, retrieved_at, + content: {type: document, source: {type: text, + media_type, data}, title?}} + Success shape (pdf): source.type=base64 + media_type= + application/pdf. We do not surface the base64 + bytes; the title + url is enough for the source + pill, and the model still sees the document + contents on its side. + Error shape: {type: web_fetch_tool_error, error_code}. + """ + inner_type = inner.get("type") or "" + if inner_type == "web_fetch_tool_error": + return f"Error: {inner.get('error_code', 'unknown')}" + url = inner.get("url", "") + document = inner.get("content") or {} + title = "" + snippet = "" + if isinstance(document, dict): + title = document.get("title") or "" + source = document.get("source") or {} + if isinstance(source, dict): + media_type = source.get("media_type") or "" + data = source.get("data") or "" + # Inline a short text preview so the source + # pill carries usable context; skip for PDFs + # since the body is base64-encoded. + if ( + media_type.startswith("text/") + and isinstance(data, str) + and data + ): + snippet = data[:240].strip() + # Frontend parseSourcesFromResult only emits a source + # pill when both `Title:` and `URL:` are present, so + # fall back to the URL when Anthropic omits the + # document title (matches the web_search formatter). + if not title and url: + title = url + parts: list[str] = [] + if title: + parts.append(f"Title: {title}") + if url: + parts.append(f"URL: {url}") + if snippet: + parts.append(f"Snippet: {snippet}") + return "\n".join(parts) if parts else "(fetch complete)" + def _format_code_execution_result( inner: dict[str, Any], ) -> str: @@ -1722,6 +1824,28 @@ class ExternalProviderClient: if isinstance(content, list) else [], } + elif ( + block_type == "server_tool_use" + and block_name == "web_fetch" + ): + tool_use_id = content_block.get("id", "") or ( + f"wf_{len(web_fetch_calls)}" + ) + current_web_fetch_use = { + "id": tool_use_id, + "buffer": "", + } + web_fetch_calls[tool_use_id] = { + "url": "", + "result": None, + } + elif block_type == "web_fetch_tool_result": + tool_use_id = content_block.get("tool_use_id", "") + inner = content_block.get("content") or {} + current_web_fetch_result = { + "tool_use_id": tool_use_id, + "inner": inner if isinstance(inner, dict) else {}, + } elif block_type == "server_tool_use" and block_name in ( "bash_code_execution", "text_editor_code_execution", @@ -1807,6 +1931,8 @@ class ExternalProviderClient: current_server_tool_use["buffer"] += partial elif current_code_exec_use is not None: current_code_exec_use["buffer"] += partial + elif current_web_fetch_use is not None: + current_web_fetch_use["buffer"] += partial # signature_delta and any other delta types are # intentionally skipped — they carry trust / # verification metadata, not user-visible content. @@ -1924,6 +2050,64 @@ class ExternalProviderClient: } ) current_code_exec_result = None + elif current_web_fetch_use is not None: + # End of the web_fetch server_tool_use — + # parse the buffered input_json into the + # URL the model asked Anthropic to fetch + # and emit tool_start. The matching + # tool_end fires on the result block's + # content_block_stop just below. + buffer = current_web_fetch_use["buffer"] + url = "" + if buffer: + try: + parsed = _json.loads(buffer) + if isinstance(parsed, dict): + probe = parsed.get("url", "") + if isinstance(probe, str): + url = probe + except Exception: + logger.debug( + "Failed to parse web_fetch input_json", + buffer = buffer, + ) + url = "" + tool_use_id = current_web_fetch_use["id"] + if tool_use_id in web_fetch_calls: + web_fetch_calls[tool_use_id]["url"] = url + yield _emit_tool_event( + { + "type": "tool_start", + "tool_name": "web_fetch", + "tool_call_id": tool_use_id, + "arguments": ({"url": url} if url else {}), + } + ) + current_web_fetch_use = None + elif current_web_fetch_result is not None: + # End of the web_fetch_tool_result — + # format Title / URL / snippet for the + # frontend source pill and emit tool_end. + # `inner` is sanitised to a dict at the + # matching content_block_start, and the + # formatter always returns a non-empty + # string (defaulting to "(fetch complete)" + # when no fields are present), so no + # extra fallback is needed here. + tool_use_id = current_web_fetch_result["tool_use_id"] + result_text = _format_web_fetch_result( + current_web_fetch_result["inner"] + ) + if tool_use_id in web_fetch_calls: + web_fetch_calls[tool_use_id]["result"] = result_text + yield _emit_tool_event( + { + "type": "tool_end", + "tool_call_id": tool_use_id, + "result": result_text, + } + ) + current_web_fetch_result = None elif thinking_open: # Close the tag when the thinking block # ends, in case no text_delta follows (e.g. @@ -1971,20 +2155,25 @@ class ExternalProviderClient: if thinking_open: yield _content_chunk("") thinking_open = False - chunk = { - "id": completion_id, - "object": "chat.completion.chunk", - "choices": [ - { - "index": 0, - "delta": {}, - "finish_reason": _finish_reason_map.get( - stop_reason, "stop" - ), - } - ], - } - yield f"data: {_json.dumps(chunk)}" + # `pause_turn` is in-progress, not terminal: + # the SSE stream still ends with [DONE] via + # message_stop but we skip emitting a + # finish_reason="stop" chunk that would + # truncate the rendered message in the UI. + mapped = _finish_reason_map.get(stop_reason, "stop") + if mapped is not None: + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": mapped, + } + ], + } + yield f"data: {_json.dumps(chunk)}" elif event_type == "message_stop": if thinking_open: @@ -2037,10 +2226,17 @@ class ExternalProviderClient: for c in code_execution_calls.values() if c.get("result") is not None ) + web_fetch_requested = web_fetch_enabled + web_fetch_invocations = len(web_fetch_calls) + web_fetch_urls = [ + wf["url"] for wf in web_fetch_calls.values() if wf.get("url") + ] logger.info( "Anthropic stream complete (model=%s, " "web_search_requested=%s, web_search_invocations=%s, " "results=%s, queries=%s, " + "web_fetch_requested=%s, web_fetch_invocations=%s, " + "web_fetch_urls=%s, " "code_execution_requested=%s, " "code_execution_invocations=%s, " "code_execution_results=%s, " @@ -2054,6 +2250,9 @@ class ExternalProviderClient: web_search_invocations, total_results, queries, + web_fetch_requested, + web_fetch_invocations, + web_fetch_urls, code_execution_enabled, code_execution_invocations, code_execution_results, diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index b33aa25935..b2376f7be4 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -605,7 +605,13 @@ class ChatCompletionRequest(BaseModel): ) enabled_tools: Optional[list[str]] = Field( None, - description = "[x-unsloth] List of enabled tool names (e.g. ['web_search', 'python', 'terminal']). If None, all tools are enabled.", + description = ( + "[x-unsloth] List of enabled tool names. Local GGUF models accept " + "['web_search', 'python', 'terminal']. External providers accept " + "['web_search', 'web_fetch', 'code_execution'] for Anthropic and " + "['web_search', 'code_execution'] for OpenAI Responses. If None, " + "all local tools are enabled and no server-side tools are forwarded." + ), ) auto_heal_tool_calls: Optional[bool] = Field( True, diff --git a/studio/backend/tests/test_anthropic_web_fetch.py b/studio/backend/tests/test_anthropic_web_fetch.py new file mode 100644 index 0000000000..115bfcb500 --- /dev/null +++ b/studio/backend/tests/test_anthropic_web_fetch.py @@ -0,0 +1,586 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Unit tests for Anthropic's server-side `web_fetch_20250910` tool +translation in `_stream_anthropic`. + +Covers: +- Request body: when ``enabled_tools=["web_fetch"]``, the outbound + ``tools`` array carries ``{"type":"web_fetch_20250910", + "name":"web_fetch", "max_uses":5}``. No beta header is required. +- Combined request: ``enabled_tools=["web_search","web_fetch", + "code_execution"]`` sends all three tool entries. +- Disabled by default: with ``enabled_tools=["web_search"]`` (or None), + the body does NOT carry a web_fetch entry. +- SSE translation (success): a `web_fetch` server_tool_use streaming + ``{"url": "..."}`` followed by a `web_fetch_tool_result` block with + a document source emits one ``tool_start`` and one ``tool_end`` + `_toolEvent`. The ``tool_start.arguments.url`` matches the fetched + URL and the ``tool_end.result`` carries the Title / URL / snippet + prefix the source-pill renderer expects. +- SSE translation (error): a `web_fetch_tool_error` with + ``error_code="url_not_accessible"`` renders as ``"Error: + url_not_accessible"`` in the tool_end result. +""" + +import asyncio +import json + +import httpx + +from core.inference import external_provider as ep_mod +from core.inference.external_provider import ExternalProviderClient + + +def _drive(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +async def _collect(agen): + out = [] + async for line in agen: + out.append(line) + return out + + +def _mock_http_client(monkeypatch, handler): + transport = httpx.MockTransport(handler) + monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport)) + + +def _make_client() -> ExternalProviderClient: + return ExternalProviderClient( + provider_type = "anthropic", + base_url = "https://api.anthropic.com/v1", + api_key = "sk-ant-test", + ) + + +def _anthropic_sse(events: list[dict]) -> bytes: + chunks: list[str] = [] + for event in events: + chunks.append(f"event: {event['type']}") + chunks.append(f"data: {json.dumps(event)}") + chunks.append("") + return ("\n".join(chunks) + "\n").encode("utf-8") + + +def _tool_events(lines: list[str]) -> list[dict]: + out: list[dict] = [] + for line in lines: + if not line.startswith("data:"): + continue + raw = line[len("data:") :].strip() + if not raw or raw == "[DONE]": + continue + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict) and "_toolEvent" in parsed: + out.append(parsed["_toolEvent"]) + return out + + +# ── request body ──────────────────────────────────────────────────── + + +def test_web_fetch_tool_appended_to_request_body(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + captured["headers"] = dict(request.headers) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "Fetch https://example.com/article"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + enabled_tools = ["web_fetch"], + ): + pass + await client.close() + + _drive(run()) + + body = captured["body"] + tools = body.get("tools") or [] + assert { + "type": "web_fetch_20250910", + "name": "web_fetch", + "max_uses": 5, + } in tools + # web_fetch is GA; no beta header is required. + assert "web-fetch" not in captured["headers"].get("anthropic-beta", "") + + +def test_web_fetch_combined_with_web_search_and_code_execution(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + captured["headers"] = dict(request.headers) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "research this"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + enabled_tools = ["web_search", "web_fetch", "code_execution"], + ): + pass + await client.close() + + _drive(run()) + + tools = captured["body"].get("tools") or [] + tool_types = [t.get("type") for t in tools] + assert "web_search_20250305" in tool_types + assert "web_fetch_20250910" in tool_types + assert "code_execution_20250825" in tool_types + # Code-execution still adds its beta flag; web_fetch must not + # have accidentally stripped it. + assert "code-execution-2025-08-25" in captured["headers"].get("anthropic-beta", "") + + +def test_no_web_fetch_tool_when_pill_off(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 64, + enabled_tools = ["web_search"], + ): + pass + await client.close() + + _drive(run()) + + tools = captured["body"].get("tools") or [] + assert all(t.get("type") != "web_fetch_20250910" for t in tools) + + +# ── SSE translation ───────────────────────────────────────────────── + + +def test_web_fetch_success_emits_tool_start_and_end(monkeypatch): + sse_events = [ + {"type": "message_start", "message": {"usage": {}}}, + # The model decides to fetch. + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "server_tool_use", + "id": "srvtoolu_wf1", + "name": "web_fetch", + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": '{"url": "https://example.com/article"}', + }, + }, + {"type": "content_block_stop", "index": 0}, + # Anthropic returns the fetched document inline. + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_wf1", + "content": { + "type": "web_fetch_result", + "url": "https://example.com/article", + "retrieved_at": "2026-05-21T12:00:00Z", + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "Article body text begins here.", + }, + "title": "Example Article", + }, + }, + }, + }, + {"type": "content_block_stop", "index": 1}, + {"type": "message_stop"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content = _anthropic_sse(sse_events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + return await _collect( + client._stream_anthropic( + messages = [ + {"role": "user", "content": "Fetch https://example.com/article"} + ], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + enabled_tools = ["web_fetch"], + ) + ) + + lines = _drive(run()) + events = _tool_events(lines) + assert len(events) == 2, f"expected 1 start + 1 end, got {events}" + start, end = events + assert start["type"] == "tool_start" + assert start["tool_name"] == "web_fetch" + assert start["tool_call_id"] == "srvtoolu_wf1" + assert start["arguments"] == {"url": "https://example.com/article"} + assert end["type"] == "tool_end" + assert end["tool_call_id"] == "srvtoolu_wf1" + # The source pill uses Title / URL / snippet as parseSourcesFromResult expects. + assert "Title: Example Article" in end["result"] + assert "URL: https://example.com/article" in end["result"] + assert "Snippet: Article body text begins here." in end["result"] + + +def test_web_fetch_error_renders_error_code(monkeypatch): + sse_events = [ + {"type": "message_start", "message": {"usage": {}}}, + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "server_tool_use", + "id": "srvtoolu_wf2", + "name": "web_fetch", + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": '{"url": "https://example.com/404"}', + }, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_wf2", + "content": { + "type": "web_fetch_tool_error", + "error_code": "url_not_accessible", + }, + }, + }, + {"type": "content_block_stop", "index": 1}, + {"type": "message_stop"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content = _anthropic_sse(sse_events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + return await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "fetch 404"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + enabled_tools = ["web_fetch"], + ) + ) + + lines = _drive(run()) + events = _tool_events(lines) + assert len(events) == 2 + end = events[1] + assert end["type"] == "tool_end" + assert end["result"] == "Error: url_not_accessible" + + +# ── pause_turn must not emit a truncating finish_reason ───────────── + + +def _finish_reasons(lines: list[str]) -> list: + """Return the finish_reason fields from every chat.completion.chunk.""" + out: list = [] + for line in lines: + if not line.startswith("data:"): + continue + raw = line[len("data:") :].strip() + if not raw or raw == "[DONE]": + continue + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + continue + if parsed.get("object") != "chat.completion.chunk": + continue + for choice in parsed.get("choices") or []: + if "finish_reason" in choice: + out.append(choice["finish_reason"]) + return out + + +def test_pause_turn_does_not_emit_finish_reason_chunk(monkeypatch): + # `pause_turn` is what Anthropic emits when a long server-tool turn + # (typically web_search / web_fetch) pauses and will resume on the + # next request. Treating it as finish_reason="stop" makes the + # OpenAI-formatted client truncate the rendered assistant message. + # The adapter must skip the chunk so the stream ends cleanly with + # [DONE] and no terminal finish_reason. + sse_events = [ + {"type": "message_start", "message": {"usage": {}}}, + { + "type": "message_delta", + "delta": {"stop_reason": "pause_turn"}, + "usage": {"input_tokens": 100, "output_tokens": 10}, + }, + {"type": "message_stop"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content = _anthropic_sse(sse_events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + return await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "Search and read."}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + enabled_tools = ["web_search", "web_fetch"], + ) + ) + + lines = _drive(run()) + # No finish_reason chunk for pause_turn -- the only completion + # signal is the [DONE] line. + assert _finish_reasons(lines) == [], lines + assert any(line.strip() == "data: [DONE]" for line in lines), lines + + +def test_end_turn_still_emits_stop_finish_reason(monkeypatch): + # Sanity: the pause_turn -> None mapping must not regress normal + # end_turn handling. + sse_events = [ + {"type": "message_start", "message": {"usage": {}}}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"input_tokens": 100, "output_tokens": 10}, + }, + {"type": "message_stop"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content = _anthropic_sse(sse_events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + return await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + ) + ) + + lines = _drive(run()) + assert _finish_reasons(lines) == ["stop"], lines + + +def test_refusal_maps_to_content_filter(monkeypatch): + sse_events = [ + {"type": "message_start", "message": {"usage": {}}}, + { + "type": "message_delta", + "delta": {"stop_reason": "refusal"}, + "usage": {"input_tokens": 100, "output_tokens": 0}, + }, + {"type": "message_stop"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content = _anthropic_sse(sse_events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + return await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + ) + ) + + lines = _drive(run()) + assert _finish_reasons(lines) == ["content_filter"], lines + + +def test_web_fetch_titleless_document_falls_back_to_url(monkeypatch): + # Anthropic may omit `document.title` on pages where the HTML + # provides nothing usable. Without a fallback the formatter would + # emit `URL: ...\nSnippet: ...` only, and the frontend's + # parseSourcesFromResult skips entries that lack a `Title:` line, + # so the source pill silently disappears. Verify the formatter + # mirrors the web_search behaviour and falls back to the URL. + sse_events = [ + {"type": "message_start", "message": {"usage": {}}}, + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "server_tool_use", + "id": "srvtoolu_wf3", + "name": "web_fetch", + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": '{"url": "https://example.com/raw"}', + }, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_wf3", + "content": { + "type": "web_fetch_result", + "url": "https://example.com/raw", + "retrieved_at": "2026-05-21T12:00:00Z", + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "Raw body without an HTML title tag.", + }, + # No `title` field on the document. + }, + }, + }, + }, + {"type": "content_block_stop", "index": 1}, + {"type": "message_stop"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content = _anthropic_sse(sse_events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + return await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "fetch raw"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + enabled_tools = ["web_fetch"], + ) + ) + + lines = _drive(run()) + events = _tool_events(lines) + assert len(events) == 2 + end = events[1] + assert end["type"] == "tool_end" + # Title must be present so parseSourcesFromResult emits a pill. + assert "Title: https://example.com/raw" in end["result"] + assert "URL: https://example.com/raw" in end["result"] + assert "Snippet: Raw body without an HTML title tag." in end["result"] diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 415d294639..7814f1892d 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -45,6 +45,7 @@ import { getExternalReasoningCapabilities, getProviderCapabilities, providerSupportsBuiltinCodeExecution, + providerSupportsBuiltinWebFetch, providerSupportsBuiltinWebSearch, } from "../provider-capabilities"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; @@ -1269,6 +1270,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { ) ? ["web_search"] : []), + // Pair web_fetch with the Search pill on any + // provider that ships it (Anthropic today). The + // common workflow is "search returns URLs, fetch + // reads them"; without web_fetch the model can + // surface a citation but cannot quote from the + // page body, which is the whole point of the + // tool. There is no separate UI toggle yet. + ...(toolsEnabled && + providerSupportsBuiltinWebFetch( + externalProvider.providerType, + ) + ? ["web_fetch"] + : []), ...(codeToolsEnabled && providerSupportsBuiltinCodeExecution( externalProvider.providerType, @@ -1646,9 +1660,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } settleFirstTokenOk(); - // Extract source parts from completed web_search tool calls + // Extract source parts from completed web_search and web_fetch + // tool calls. Both emit the same `Title:` / `URL:` / `Snippet:` + // block shape from the Anthropic backend, so the parser does + // not need to branch on tool name. const sourceParts = toolCallParts.flatMap((tc) => { - if (tc.toolName !== "web_search" || !tc.result) return []; + if ( + (tc.toolName !== "web_search" && tc.toolName !== "web_fetch") || + !tc.result + ) { + return []; + } return parseSourcesFromResult(typeof tc.result === "string" ? tc.result : ""); }); diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index 3c9bff40b9..8b16c2c85f 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -121,6 +121,20 @@ export function providerSupportsBuiltinWebSearch( ); } +/** + * Whether the external provider exposes a server-side web_fetch tool + * that retrieves a single URL (text or PDF) and emits a document block. + * Only Anthropic ships one today (`web_fetch_20250910`); the chat + * composer pairs it with the Search pill because the typical workflow + * is "search returns URLs, fetch reads them" and the UI doesn't (yet) + * expose web_fetch as an independent toggle. + */ +export function providerSupportsBuiltinWebFetch( + providerType: string | null | undefined, +): boolean { + return providerType === "anthropic"; +} + /** * Whether the selected external provider/model exposes a server-side * code-execution tool. Two providers ship one today: