From fa074e8bde2201e36922f0b6294b328363e91e38 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 23 May 2026 17:44:54 +0000 Subject: [PATCH 1/7] 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. --- .../src/features/chat/api/chat-adapter.ts | 33 +++++++++++------- .../frontend/src/features/chat/chat-page.tsx | 25 ++++++++++++++ .../features/chat/provider-capabilities.ts | 10 +++--- .../src/features/chat/shared-composer.tsx | 34 ++++++++++++++++++- .../chat/stores/chat-runtime-store.ts | 30 ++++++++++++++++ 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 0c557f1b01..1cbe7268d3 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -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 ( @@ -889,14 +895,18 @@ 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 per + // web_fetch invocation separately from web_search hits, so + // bundling them used to make the cost surface confusing and + // blocked "just fetch this URL" workflows where the user knows + // exactly which page they want read. Source from + // `webFetchToolsEnabled` directly; on providers that don't ship + // web_fetch `providerSupportsBuiltinWebFetch` returns false and + // the toggle is forced off in chat-page's runtime setState. const webFetchEnabledForThisTurn = Boolean( externalProvider && - toolsEnabled && + webFetchToolsEnabled && providerSupportsBuiltinWebFetch(externalProvider.providerType), ); const providerShipsWebFetch = Boolean( @@ -1419,13 +1429,10 @@ 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 ships as a standalone toggle (Fetch + // pill), independent of Search. Anthropic is the + // only provider that ships it today and bills it + // per invocation separately from web_search hits. ...(webFetchEnabledForThisTurn ? ["web_fetch"] : []), ...(codeExecEnabledForThisTurn ? ["code_execution"] : []), // OpenAI Responses-API only: `image_generation` diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index ce02b0da18..290288870f 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -57,6 +57,7 @@ import { getProviderCapabilities, providerSupportsBuiltinCodeExecution, providerSupportsBuiltinImageGeneration, + providerSupportsBuiltinWebFetch, providerSupportsBuiltinWebSearch, } from "./provider-capabilities"; import { ChatRuntimeProvider } from "./runtime-provider"; @@ -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"; @@ -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 @@ -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 @@ -834,6 +842,7 @@ export function ChatPage(): ReactElement { supportsBuiltinWebSearch, supportsBuiltinCodeExecution, supportsBuiltinImageGeneration, + supportsBuiltinWebFetch, toolsEnabled: nextToolsEnabled, codeToolsEnabled: supportsBuiltinCodeExecution ? (storedCodeToolsEnabled ?? false) @@ -841,6 +850,12 @@ export function ChatPage(): ReactElement { imageToolsEnabled: supportsBuiltinImageGeneration ? (storedImageToolsEnabled ?? false) : false, + // Default Fetch off when the user has not chosen one yet. Anthropic + // bills per fetch; surfacing the toggle visible but inactive keeps + // it a deliberate opt-in rather than an implicit cost. + webFetchToolsEnabled: supportsBuiltinWebFetch + ? (storedWebFetchToolsEnabled ?? false) + : false, }); }, [externalProvidersForChat, inferenceParams.checkpoint]); const canCompare = useMemo(() => { @@ -1008,6 +1023,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. @@ -1026,6 +1044,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 @@ -1063,6 +1084,7 @@ export function ChatPage(): ReactElement { supportsBuiltinWebSearch, supportsBuiltinCodeExecution, supportsBuiltinImageGeneration, + supportsBuiltinWebFetch, toolsEnabled: nextToolsEnabled, codeToolsEnabled: supportsBuiltinCodeExecution ? (storedCodeToolsEnabled ?? false) @@ -1070,6 +1092,9 @@ export function ChatPage(): ReactElement { imageToolsEnabled: supportsBuiltinImageGeneration ? (storedImageToolsEnabled ?? false) : false, + webFetchToolsEnabled: supportsBuiltinWebFetch + ? (storedWebFetchToolsEnabled ?? false) + : false, ...(stillOnOpenRouterFree ? {} : { lastOpenRouterChosenModel: null }), }); return; diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index da1d6e3431..4e8ee97b42 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -124,10 +124,12 @@ 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. + * Only Anthropic ships one today (`web_fetch_20250910` / + * `web_fetch_20260209`). Gates the chat composer's standalone Fetch + * pill so users can read a specific URL or PDF without also turning + * on web_search. Earlier revisions bundled web_fetch with the Search + * pill, but Anthropic bills each fetch separately from search hits + * and the bundled toggle blocked "just fetch this one URL" workflows. */ export function providerSupportsBuiltinWebFetch( providerType: string | null | undefined, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 246fd81510..a40f3135fd 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -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"; @@ -34,6 +34,7 @@ import { getExternalReasoningCapabilities, providerSupportsBuiltinCodeExecution, providerSupportsBuiltinImageGeneration, + providerSupportsBuiltinWebFetch, } from "./provider-capabilities"; import { type CompositionEvent, @@ -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, ); @@ -426,6 +433,9 @@ export function SharedComposer({ effectiveExternalModelId, selectedExternalProvider?.baseUrl, ); + const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch( + selectedExternalProvider?.providerType, + ); const searchDisabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch); const codeDisabled = @@ -437,6 +447,11 @@ export function SharedComposer({ // the pill row stays compact for providers without the capability. const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; const showImagePill = supportsBuiltinImageGeneration; + // Fetch pill is Anthropic-only today (web_fetch_20250910 / + // web_fetch_20260209). Hidden on providers that don't ship it so the + // pill row stays clean. + 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; @@ -1106,6 +1121,23 @@ export function SharedComposer({ Images )} + {showWebFetchPill && ( + + )}
{dictationSupported && ( diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index a00b53a44c..1607e774e0 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -25,6 +25,8 @@ export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled"; export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled"; export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled"; export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled"; +export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY = + "unsloth_chat_web_fetch_tools_enabled"; // External provider selection is encoded into `params.checkpoint` as // `external::::`. PersistedChatSettings deliberately @@ -262,9 +264,27 @@ type ChatRuntimeStore = { * receive the tool because their runtime cannot dispatch it. */ supportsBuiltinImageGeneration: boolean; + /** + * Whether the active external provider exposes a server-side + * web_fetch tool (Anthropic's `web_fetch_20250910` / + * `web_fetch_20260209`). Gates the chat composer's Fetch pill so + * users can read a URL or PDF without also turning on web_search. + * Web fetch used to be implicitly bundled with the Search pill + * because the typical workflow is "search returns URLs, fetch + * reads them", but enabling them together prevented "just fetch + * this one URL" workflows and made the cost surface confusing + * (each fetch is metered separately from search hits). + */ + supportsBuiltinWebFetch: boolean; toolsEnabled: boolean; codeToolsEnabled: boolean; imageToolsEnabled: boolean; + /** + * Standalone Fetch pill state for the chat composer. Independent + * of `toolsEnabled` (Search pill) and only consulted when the + * active provider returns true from `providerSupportsBuiltinWebFetch`. + */ + webFetchToolsEnabled: boolean; toolStatus: string | null; generatingStatus: string | null; autoHealToolCalls: boolean; @@ -324,6 +344,7 @@ type ChatRuntimeStore = { setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void; setCodeToolsEnabled: (enabled: boolean) => void; setImageToolsEnabled: (enabled: boolean) => void; + setWebFetchToolsEnabled: (enabled: boolean) => void; setToolStatus: (status: string | null) => void; setGeneratingStatus: (status: string | null) => void; setAutoHealToolCalls: (enabled: boolean) => void; @@ -564,9 +585,11 @@ export const useChatRuntimeStore = create((set, get) => ({ supportsBuiltinWebSearch: false, supportsBuiltinCodeExecution: false, supportsBuiltinImageGeneration: false, + supportsBuiltinWebFetch: false, toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false), codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false), imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false), + webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false), toolStatus: null, generatingStatus: null, autoHealToolCalls: true, @@ -744,9 +767,11 @@ export const useChatRuntimeStore = create((set, get) => ({ supportsBuiltinWebSearch: false, supportsBuiltinCodeExecution: false, supportsBuiltinImageGeneration: false, + supportsBuiltinWebFetch: false, toolsEnabled: false, codeToolsEnabled: false, imageToolsEnabled: false, + webFetchToolsEnabled: false, toolStatus: null, kvCacheDtype: null, loadedKvCacheDtype: null, @@ -806,6 +831,11 @@ export const useChatRuntimeStore = create((set, get) => ({ saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled); return { imageToolsEnabled }; }), + setWebFetchToolsEnabled: (webFetchToolsEnabled) => + set(() => { + saveBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, webFetchToolsEnabled); + return { webFetchToolsEnabled }; + }), setToolStatus: (toolStatus) => set({ toolStatus }), setGeneratingStatus: (generatingStatus) => set({ generatingStatus }), setAutoHealToolCalls: (autoHealToolCalls) => From 14142eed3f27953f29c5a4b8aa78cc4ed24c8af2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 23 May 2026 18:35:17 +0000 Subject: [PATCH 2/7] ci: re-trigger after transient GitHub API HTTP flake (checkout + ggml-org release fetch) From 9d2b0c20f5fcaedd102461b6082e9dd6ac49712e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 23 May 2026 18:37:12 +0000 Subject: [PATCH 3/7] 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. --- .../frontend/src/features/chat/api/chat-adapter.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 1cbe7268d3..ea29e22fe8 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -951,14 +951,24 @@ 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 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. Earlier + // revisions checked webSearchEnabledForThisTurn alone, which + // mis-fired when the standalone Fetch pill was on and Search + // was off and told Claude it had no web_fetch when in fact it + // did, suppressing live web_fetch tool 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. " + From 523fac273928c1ae2e2a4707e1e6c5b04886b35f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 23 May 2026 19:34:46 +0000 Subject: [PATCH 4/7] ci: re-trigger after transient infra flake on Windows prebuilt / actions/checkout From b20c41e5f4db4ae6f626cfe8fc9ae9d64d2f1acc Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 25 May 2026 13:34:02 +0000 Subject: [PATCH 5/7] 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. --- .../core/inference/external_provider.py | 9 ++++-- .../backend/tests/test_anthropic_web_fetch.py | 29 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 25e1725337..629163cd69 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -1521,13 +1521,18 @@ async def _stream_anthropic( # 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. + # generic tool_end payload. The tool type is date-pinned per + # model family; `_anthropic_web_fetch_version` picks + # `web_fetch_20260209` (dynamic filtering) on Opus 4.6/4.7 and + # Sonnet 4.6 and falls back to `web_fetch_20250910` everywhere + # else. Sending the new variant to an older model returns 400 + # "tool not supported", so the 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, } diff --git a/studio/backend/tests/test_anthropic_web_fetch.py b/studio/backend/tests/test_anthropic_web_fetch.py index cdb5f6254c..f530364197 100644 --- a/studio/backend/tests/test_anthropic_web_fetch.py +++ b/studio/backend/tests/test_anthropic_web_fetch.py @@ -2,13 +2,17 @@ # 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 +Unit tests for Anthropic's server-side `web_fetch_20250910` / +`web_fetch_20260209` 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. + ``tools`` array carries ``{"type":"web_fetch_", + "name":"web_fetch", "max_uses":5}`` where the version is picked per + model by ``_anthropic_web_fetch_version`` (``_20260209`` for Opus + 4.6/4.7 + Sonnet 4.6, ``_20250910`` otherwise). 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), @@ -117,8 +121,11 @@ async def run(): body = captured["body"] tools = body.get("tools") or [] + # claude-opus-4-7 routes web_fetch to the _20260209 variant (dynamic + # filtering); older models fall back to _20250910 via + # _anthropic_web_fetch_version. assert { - "type": "web_fetch_20250910", + "type": "web_fetch_20260209", "name": "web_fetch", "max_uses": 5, } in tools @@ -158,12 +165,11 @@ 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 to the _20260209 variant, + # web_fetch to _20260209 (dynamic filtering), and code_execution + # to _20260120. 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. @@ -199,7 +205,10 @@ 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 ───────────────────────────────────────────────── From 2872e39150bcceb881ca7c2b97edd37af9287f1f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 13:34:50 +0000 Subject: [PATCH 6/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_anthropic_web_fetch.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/studio/backend/tests/test_anthropic_web_fetch.py b/studio/backend/tests/test_anthropic_web_fetch.py index f530364197..f7fca81141 100644 --- a/studio/backend/tests/test_anthropic_web_fetch.py +++ b/studio/backend/tests/test_anthropic_web_fetch.py @@ -206,8 +206,7 @@ async def run(): tools = captured["body"].get("tools") or [] assert all( - t.get("type") not in ("web_fetch_20250910", "web_fetch_20260209") - for t in tools + t.get("type") not in ("web_fetch_20250910", "web_fetch_20260209") for t in tools ) From ea98aeb0307fc2f948c8482a9ff77dcfe6bc7970 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 26 May 2026 05:50:22 +0000 Subject: [PATCH 7/7] Shorten web_fetch comments for PR #5742 --- .../core/inference/external_provider.py | 25 ++++-------- .../backend/tests/test_anthropic_web_fetch.py | 40 +++++-------------- .../src/features/chat/api/chat-adapter.ts | 30 +++++--------- .../frontend/src/features/chat/chat-page.tsx | 4 +- .../features/chat/provider-capabilities.ts | 10 ++--- .../src/features/chat/shared-composer.tsx | 4 +- .../chat/stores/chat-runtime-store.ts | 14 ++----- 7 files changed, 35 insertions(+), 92 deletions(-) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 629163cd69..52611f774e 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -1509,24 +1509,13 @@ 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. The tool type is date-pinned per - # model family; `_anthropic_web_fetch_version` picks - # `web_fetch_20260209` (dynamic filtering) on Opus 4.6/4.7 and - # Sonnet 4.6 and falls back to `web_fetch_20250910` everywhere - # else. Sending the new variant to an older model returns 400 - # "tool not supported", so the picker is required. + # 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 []) diff --git a/studio/backend/tests/test_anthropic_web_fetch.py b/studio/backend/tests/test_anthropic_web_fetch.py index f7fca81141..facef76d26 100644 --- a/studio/backend/tests/test_anthropic_web_fetch.py +++ b/studio/backend/tests/test_anthropic_web_fetch.py @@ -2,30 +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` / -`web_fetch_20260209` tool -translation in `_stream_anthropic`. - -Covers: -- Request body: when ``enabled_tools=["web_fetch"]``, the outbound - ``tools`` array carries ``{"type":"web_fetch_", - "name":"web_fetch", "max_uses":5}`` where the version is picked per - model by ``_anthropic_web_fetch_version`` (``_20260209`` for Opus - 4.6/4.7 + Sonnet 4.6, ``_20250910`` otherwise). 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 @@ -121,9 +103,7 @@ async def run(): body = captured["body"] tools = body.get("tools") or [] - # claude-opus-4-7 routes web_fetch to the _20260209 variant (dynamic - # filtering); older models fall back to _20250910 via - # _anthropic_web_fetch_version. + # claude-opus-4-7 routes web_fetch to _20260209 (dynamic filtering). assert { "type": "web_fetch_20260209", "name": "web_fetch", @@ -164,10 +144,8 @@ 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, - # web_fetch to _20260209 (dynamic filtering), and code_execution - # to _20260120. + # 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 diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index ea29e22fe8..37f1a18dd5 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -895,14 +895,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { externalProvider.baseUrl, ), ); - // Fetch pill is independent of Search. Anthropic bills per - // web_fetch invocation separately from web_search hits, so - // bundling them used to make the cost surface confusing and - // blocked "just fetch this URL" workflows where the user knows - // exactly which page they want read. Source from - // `webFetchToolsEnabled` directly; on providers that don't ship - // web_fetch `providerSupportsBuiltinWebFetch` returns false and - // the toggle is forced off in chat-page's runtime setState. + // 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 && @@ -951,14 +947,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { const webLabel = providerShipsWebFetch ? "web search or web fetch" : "web search"; - // 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. Earlier - // revisions checked webSearchEnabledForThisTurn alone, which - // mis-fired when the standalone Fetch pill was on and Search - // was off and told Claude it had no web_fetch when in fact it - // did, suppressing live web_fetch tool calls. + // 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) { @@ -1439,10 +1431,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { enable_tools: true, enabled_tools: [ ...(webSearchEnabledForThisTurn ? ["web_search"] : []), - // web_fetch ships as a standalone toggle (Fetch - // pill), independent of Search. Anthropic is the - // only provider that ships it today and bills it - // per invocation separately from web_search hits. + // 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` diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 290288870f..eba7b99f99 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -850,9 +850,7 @@ export function ChatPage(): ReactElement { imageToolsEnabled: supportsBuiltinImageGeneration ? (storedImageToolsEnabled ?? false) : false, - // Default Fetch off when the user has not chosen one yet. Anthropic - // bills per fetch; surfacing the toggle visible but inactive keeps - // it a deliberate opt-in rather than an implicit cost. + // Default Fetch off (Anthropic bills per fetch); deliberate opt-in. webFetchToolsEnabled: supportsBuiltinWebFetch ? (storedWebFetchToolsEnabled ?? false) : false, diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index 4e8ee97b42..af0b1fb378 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -123,13 +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` / - * `web_fetch_20260209`). Gates the chat composer's standalone Fetch - * pill so users can read a specific URL or PDF without also turning - * on web_search. Earlier revisions bundled web_fetch with the Search - * pill, but Anthropic bills each fetch separately from search hits - * and the bundled toggle blocked "just fetch this one URL" workflows. + * (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, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index a40f3135fd..76e77d1288 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -447,9 +447,7 @@ export function SharedComposer({ // the pill row stays compact for providers without the capability. const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; const showImagePill = supportsBuiltinImageGeneration; - // Fetch pill is Anthropic-only today (web_fetch_20250910 / - // web_fetch_20260209). Hidden on providers that don't ship it so the - // pill row stays clean. + // 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 diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 1607e774e0..e0d8ad753d 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -267,22 +267,16 @@ type ChatRuntimeStore = { /** * Whether the active external provider exposes a server-side * web_fetch tool (Anthropic's `web_fetch_20250910` / - * `web_fetch_20260209`). Gates the chat composer's Fetch pill so - * users can read a URL or PDF without also turning on web_search. - * Web fetch used to be implicitly bundled with the Search pill - * because the typical workflow is "search returns URLs, fetch - * reads them", but enabling them together prevented "just fetch - * this one URL" workflows and made the cost surface confusing - * (each fetch is metered separately from search hits). + * `web_fetch_20260209`). Gates the composer's Fetch pill, + * independent of Search. */ supportsBuiltinWebFetch: boolean; toolsEnabled: boolean; codeToolsEnabled: boolean; imageToolsEnabled: boolean; /** - * Standalone Fetch pill state for the chat composer. Independent - * of `toolsEnabled` (Search pill) and only consulted when the - * active provider returns true from `providerSupportsBuiltinWebFetch`. + * Fetch pill state, independent of `toolsEnabled` (Search). Only + * consulted when `providerSupportsBuiltinWebFetch` is true. */ webFetchToolsEnabled: boolean; toolStatus: string | null;