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:
parent
2201fd687b
commit
a2d2b7866f
5 changed files with 845 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
586
studio/backend/tests/test_anthropic_web_fetch.py
Normal file
586
studio/backend/tests/test_anthropic_web_fetch.py
Normal 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"]
|
||||
|
|
@ -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 : "");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue