* Studio: surface Anthropic web_fetch as a standalone Fetch pill web_fetch used to be silently bundled with the Search pill on the assumption that "search returns URLs, fetch reads them" is the typical workflow. Two problems with that: - Anthropic bills each web_fetch invocation separately from web_search hits, so combining them made the per-message cost surface ambiguous. - It blocked "just fetch this one URL" workflows where the user already knows the page they want read and does not want a search round-trip. Adds: - `webFetchToolsEnabled` to the chat-runtime-store, persisted to localStorage under `unsloth_chat_web_fetch_tools_enabled`, with a matching `supportsBuiltinWebFetch` capability flag and a `setWebFetchToolsEnabled` setter. - A new Fetch pill in the chat composer, rendered next to Images and only when the active provider returns true from `providerSupportsBuiltinWebFetch` (Anthropic today). The pill defaults off so per-fetch billing is always a deliberate opt-in. - chat-page bootstraps `webFetchToolsEnabled` from the same stored- preference fallback the other pills use. - chat-adapter reads `webFetchToolsEnabled` directly when deciding whether to append "web_fetch" to `enabled_tools`, decoupling it from `toolsEnabled` (Search). Backend translation is unchanged: when `enabled_tools` already contains "web_fetch", `_stream_anthropic` appends the `web_fetch_20250910` / `web_fetch_20260209` tool exactly as before (test_anthropic_web_fetch.py pins the standalone-only path at `test_web_fetch_tool_appended_to_request_body` and the combined path at `test_web_fetch_combined_with_web_search_and_code_execution`). Frontend tsc passes. * ci: re-trigger after transient GitHub API HTTP flake (checkout + ggml-org release fetch) * Studio: include web_fetch in the disabled-tool guard axis Reviewer P1 / High on PR #5742 (codex + gemini): after introducing the standalone Fetch pill, `disabledToolGuard` still only branched on `webSearchEnabledForThisTurn`. With Fetch ON and Search OFF the system prompt would tell Claude "you do not have web search or web fetch tools in this conversation", which contradicts the actual tool schema being sent and suppresses `web_fetch` tool calls, defeating the standalone-fetch workflow this PR adds. Treat search and fetch as a single "any web tool enabled" axis. The guard only needs to warn the model when no web tool is wired in for this turn; once either pill is on the model can pick the right one from the tool schema. The existing `webLabel` already covers both names, so the user-visible guard text stays accurate in every combination. tsc clean. * ci: re-trigger after transient infra flake on Windows prebuilt / actions/checkout * Studio: route web_fetch through per-model version dispatch The web_fetch tool body in `_stream_anthropic` hardcoded `web_fetch_20250910` instead of calling `_anthropic_web_fetch_version`, so Opus 4.6 / 4.7 and Sonnet 4.6 missed the `web_fetch_20260209` dynamic-filtering variant. The picker, the unit tests for it, and a deliberate "follow-up" note in `test_anthropic_web_fetch.py` already existed; this just threads it through the emission site. Mirrors how web_search and code_execution are dispatched per model. Old models still resolve to `web_fetch_20250910` and continue to work. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Shorten web_fetch comments for PR #5742 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
580 lines
19 KiB
Python
580 lines
19 KiB
Python
# 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 `web_fetch_20250910` / `web_fetch_20260209`
|
|
translation in ``_stream_anthropic``. Covers request body emission
|
|
(version picked by ``_anthropic_web_fetch_version``: ``_20260209`` for
|
|
Opus 4.6/4.7 + Sonnet 4.6, ``_20250910`` otherwise), combined tool
|
|
requests, off-by-default behavior, and SSE translation of success and
|
|
``url_not_accessible`` error paths into ``tool_start`` / ``tool_end``.
|
|
"""
|
|
|
|
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 []
|
|
# claude-opus-4-7 routes web_fetch to _20260209 (dynamic filtering).
|
|
assert {
|
|
"type": "web_fetch_20260209",
|
|
"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]
|
|
# claude-opus-4-7 routes web_search and web_fetch to _20260209
|
|
# and code_execution to _20260120 (per PR 5679 dispatch).
|
|
assert "web_search_20260209" in tool_types, tool_types
|
|
assert "web_fetch_20260209" in tool_types, tool_types
|
|
assert "code_execution_20260120" in tool_types, 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") not in ("web_fetch_20250910", "web_fetch_20260209") 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 non-null finish_reason fields from each chat.completion.chunk.
|
|
Mid-stream content deltas carry ``finish_reason: None`` and are skipped
|
|
(the refusal path emits a notice delta before the content_filter 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 []:
|
|
reason = choice.get("finish_reason")
|
|
if reason is not None:
|
|
out.append(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"]
|