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: