Skip to content
22 changes: 8 additions & 14 deletions studio/backend/core/inference/external_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1509,25 +1509,19 @@ async def _stream_anthropic(
)
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.
# Anthropic server-side web_fetch reads a single URL (text/PDF)
# and returns a `web_fetch_tool_result` document block. Opt in
# via `enabled_tools=["web_fetch"]`; no beta header required.
# `_anthropic_web_fetch_version` picks `web_fetch_20260209`
# (dynamic filtering) for Opus 4.6/4.7 + Sonnet 4.6, falling
# back to `web_fetch_20250910` elsewhere; mismatched variants
# return 400 so the per-model picker is required.
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",
"type": _anthropic_web_fetch_version(model),
"name": "web_fetch",
"max_uses": 5,
}
Expand Down
42 changes: 14 additions & 28 deletions studio/backend/tests/test_anthropic_web_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,12 @@
# 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.
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
Expand Down Expand Up @@ -117,8 +103,9 @@ async def run():

body = captured["body"]
tools = body.get("tools") or []
# claude-opus-4-7 routes web_fetch to _20260209 (dynamic filtering).
assert {
"type": "web_fetch_20250910",
"type": "web_fetch_20260209",
"name": "web_fetch",
"max_uses": 5,
} in tools
Expand Down Expand Up @@ -157,13 +144,10 @@ async def run():

tools = captured["body"].get("tools") or []
tool_types = [t.get("type") for t in tools]
# After PR 5679's per-model tool version dispatch landed,
# claude-opus-4-7 routes web_search to the _20260209 variant and
# code_execution to _20260120. web_fetch still hardcodes
# _20250910 today; see follow-up to thread it through
# _anthropic_web_fetch_version.
# 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_20250910" 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.
Expand Down Expand Up @@ -199,7 +183,9 @@ async def run():
_drive(run())

tools = captured["body"].get("tools") or []
assert all(t.get("type") != "web_fetch_20250910" for t in tools)
assert all(
t.get("type") not in ("web_fetch_20250910", "web_fetch_20260209") for t in tools
)


# ── SSE translation ─────────────────────────────────────────────────
Expand Down
37 changes: 22 additions & 15 deletions studio/frontend/src/features/chat/api/chat-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Re-read store after potential auto-load / model ready wait
runtime = useChatRuntimeStore.getState();
const { params } = runtime;
const { supportsTools, toolsEnabled, codeToolsEnabled, imageToolsEnabled } = runtime;
const {
supportsTools,
toolsEnabled,
codeToolsEnabled,
imageToolsEnabled,
webFetchToolsEnabled,
} = runtime;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
if (
Expand Down Expand Up @@ -889,14 +895,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
externalProvider.baseUrl,
),
);
// web_fetch shares the Search pill with web_search (no separate
// UI toggle), so it follows toolsEnabled. Anthropic is the only
// provider that ships it today; on others providerSupportsBuiltinWebFetch
// returns false and this stays inert.
// Fetch pill is independent of Search (Anthropic bills web_fetch
// separately from web_search). Sourced from `webFetchToolsEnabled`;
// on providers without web_fetch the toggle is forced off in
// chat-page's runtime setState.
const webFetchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
webFetchToolsEnabled &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
Comment on lines 902 to 906

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat web_fetch as available in disabled-tool guard

After introducing the standalone webFetchToolsEnabled path, createOpenAIStreamAdapter computes webFetchEnabledForThisTurn but the disabledToolGuard branching still only checks webSearchEnabledForThisTurn/codeExecEnabledForThisTurn, so an Anthropic turn with Fetch ON and Search OFF is told “you do not have web search or web fetch tools.” In that state the model is instructed not to use a capability that is actually enabled, which can suppress web_fetch tool calls and break the new standalone-fetch workflow.

Useful? React with 👍 / 👎.

);
Comment on lines 902 to 907

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for webFetchEnabledForThisTurn is correctly implemented here, but it appears that the disabledToolGuard logic (around line 954) was not updated to account for this new standalone tool.

Currently, if a user enables the Fetch pill but leaves Search and Code disabled, the system prompt will incorrectly inform the model that it does not have access to web fetch capabilities. You should update the guard conditions to check webFetchEnabledForThisTurn as well.

const providerShipsWebFetch = Boolean(
Expand Down Expand Up @@ -941,14 +947,20 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const webLabel = providerShipsWebFetch
? "web search or web fetch"
: "web search";
if (!webSearchEnabledForThisTurn && !codeExecEnabledForThisTurn) {
// Treat search and fetch as a single "any web tool" axis so
// the guard only warns when neither pill is on; checking
// webSearchEnabledForThisTurn alone mis-fired when only Fetch
// was on and suppressed live web_fetch calls.
const anyWebEnabledForThisTurn =
webSearchEnabledForThisTurn || webFetchEnabledForThisTurn;
if (!anyWebEnabledForThisTurn && !codeExecEnabledForThisTurn) {
disabledToolGuard =
`You do not have ${webLabel} or code execution tools in this conversation. ` +
"Answer from your own knowledge. " +
"If a request genuinely requires tool use, live data fetch or running code, " +
"inform the user that you do not have access to these capabilities. " +
"Do not return tool-call syntax inside your response.";
} else if (!webSearchEnabledForThisTurn) {
} else if (!anyWebEnabledForThisTurn) {
disabledToolGuard =
`You do not have ${webLabel} tools in this conversation. ` +
"You may still use code execution tools when they are available and useful. " +
Expand Down Expand Up @@ -1419,13 +1431,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
enable_tools: true,
enabled_tools: [
...(webSearchEnabledForThisTurn ? ["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.
// web_fetch has its own Fetch pill, independent
// of Search. Anthropic-only today.
...(webFetchEnabledForThisTurn ? ["web_fetch"] : []),
...(codeExecEnabledForThisTurn ? ["code_execution"] : []),
// OpenAI Responses-API only: `image_generation`
Expand Down
23 changes: 23 additions & 0 deletions studio/frontend/src/features/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
getProviderCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinImageGeneration,
providerSupportsBuiltinWebFetch,
providerSupportsBuiltinWebSearch,
} from "./provider-capabilities";
import { ChatRuntimeProvider } from "./runtime-provider";
Expand All @@ -71,6 +72,7 @@ import {
CHAT_CODE_TOOLS_ENABLED_KEY,
CHAT_IMAGE_TOOLS_ENABLED_KEY,
CHAT_TOOLS_ENABLED_KEY,
CHAT_WEB_FETCH_TOOLS_ENABLED_KEY,
loadOptionalBool,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
Expand Down Expand Up @@ -779,6 +781,9 @@ export function ChatPage(): ReactElement {
selection.modelId,
provider?.baseUrl,
);
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
provider?.providerType,
);
// Kimi's k2.6/k2.5 default to thinking enabled on the server side
// (per https://platform.kimi.ai/docs/models). Mirror that default
// in the UI so the Think pill comes up clicked when the user picks
Expand All @@ -801,6 +806,9 @@ export function ChatPage(): ReactElement {
const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
const storedWebFetchToolsEnabled = loadOptionalBool(
CHAT_WEB_FETCH_TOOLS_ENABLED_KEY,
);
const nextToolsEnabled = supportsBuiltinWebSearch
? isKimi
? false
Expand Down Expand Up @@ -834,13 +842,18 @@ export function ChatPage(): ReactElement {
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
supportsBuiltinImageGeneration,
supportsBuiltinWebFetch,
toolsEnabled: nextToolsEnabled,
codeToolsEnabled: supportsBuiltinCodeExecution
? (storedCodeToolsEnabled ?? false)
: false,
imageToolsEnabled: supportsBuiltinImageGeneration
? (storedImageToolsEnabled ?? false)
: false,
// Default Fetch off (Anthropic bills per fetch); deliberate opt-in.
webFetchToolsEnabled: supportsBuiltinWebFetch
? (storedWebFetchToolsEnabled ?? false)
: false,
});
}, [externalProvidersForChat, inferenceParams.checkpoint]);
const canCompare = useMemo(() => {
Expand Down Expand Up @@ -1008,6 +1021,9 @@ export function ChatPage(): ReactElement {
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
selectedProvider?.providerType,
);
// See sibling useEffect above: Kimi's k2.x default to thinking
// enabled, so the Think pill comes up clicked. Search pill stays
// off by default; mutual exclusion flips them via the composer.
Expand All @@ -1026,6 +1042,9 @@ export function ChatPage(): ReactElement {
const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
const storedWebFetchToolsEnabled = loadOptionalBool(
CHAT_WEB_FETCH_TOOLS_ENABLED_KEY,
);
const nextToolsEnabled = supportsBuiltinWebSearch
? isKimi
? false
Expand Down Expand Up @@ -1063,13 +1082,17 @@ export function ChatPage(): ReactElement {
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
supportsBuiltinImageGeneration,
supportsBuiltinWebFetch,
toolsEnabled: nextToolsEnabled,
codeToolsEnabled: supportsBuiltinCodeExecution
? (storedCodeToolsEnabled ?? false)
: false,
imageToolsEnabled: supportsBuiltinImageGeneration
? (storedImageToolsEnabled ?? false)
: false,
webFetchToolsEnabled: supportsBuiltinWebFetch
? (storedWebFetchToolsEnabled ?? false)
: false,
...(stillOnOpenRouterFree ? {} : { lastOpenRouterChosenModel: null }),
});
return;
Expand Down
8 changes: 3 additions & 5 deletions studio/frontend/src/features/chat/provider-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,9 @@ 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.
* (single URL, text or PDF) emitting a document block. Anthropic-only
* today (`web_fetch_20250910` / `web_fetch_20260209`). Gates the
* composer's standalone Fetch pill, independent of Search.
*/
export function providerSupportsBuiltinWebFetch(
providerType: string | null | undefined,
Expand Down
32 changes: 31 additions & 1 deletion studio/frontend/src/features/chat/shared-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, ImageIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { ArrowUpIcon, DownloadIcon, GlobeIcon, HeadphonesIcon, ImageIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
Expand All @@ -34,6 +34,7 @@ import {
getExternalReasoningCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinImageGeneration,
providerSupportsBuiltinWebFetch,
} from "./provider-capabilities";
import {
type CompositionEvent,
Expand Down Expand Up @@ -336,6 +337,12 @@ export function SharedComposer({
const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled,
);
const webFetchToolsEnabled = useChatRuntimeStore(
(s) => s.webFetchToolsEnabled,
);
const setWebFetchToolsEnabled = useChatRuntimeStore(
(s) => s.setWebFetchToolsEnabled,
);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
Expand Down Expand Up @@ -426,6 +433,9 @@ export function SharedComposer({
effectiveExternalModelId,
selectedExternalProvider?.baseUrl,
);
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
selectedExternalProvider?.providerType,
);
const searchDisabled =
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
const codeDisabled =
Expand All @@ -437,6 +447,9 @@ export function SharedComposer({
// the pill row stays compact for providers without the capability.
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
const showImagePill = supportsBuiltinImageGeneration;
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
// Backwards-compatible alias for any other call site that may still
// reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
Expand Down Expand Up @@ -1106,6 +1119,23 @@ export function SharedComposer({
<span>Images</span>
</button>
)}
{showWebFetchPill && (
<button
type="button"
disabled={webFetchDisabled}
onClick={() => setWebFetchToolsEnabled(!webFetchToolsEnabled)}
className="composer-pill-btn"
data-active={
webFetchToolsEnabled && !webFetchDisabled ? "true" : "false"
}
aria-label={
webFetchToolsEnabled ? "Disable URL fetch" : "Enable URL fetch"
}
>
<DownloadIcon className="size-3.5" />
<span>Fetch</span>
</button>
)}
</div>
<div className="flex items-center gap-1">
{dictationSupported && (
Expand Down
Loading
Loading