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: <error_code>" 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>
This commit is contained in:
Daniel Han 2026-05-22 06:03:48 -07:00 committed by GitHub
commit a2d2b7866f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 845 additions and 18 deletions

View file

@ -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 <think> 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("</think>")
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,

View file

@ -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,

View file

@ -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"]

View file

@ -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 : "");
});

View file

@ -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: