Skip to content

Studio: standalone Fetch pill for Anthropic web_fetch#5742

Merged
danielhanchen merged 8 commits into
mainfrom
feat/anthropic-web-fetch-standalone
May 26, 2026
Merged

Studio: standalone Fetch pill for Anthropic web_fetch#5742
danielhanchen merged 8 commits into
mainfrom
feat/anthropic-web-fetch-standalone

Conversation

@danielhanchen
Copy link
Copy Markdown
Member

Summary

  • Decouples Anthropic's server-side web_fetch_20250910 / web_fetch_20260209 from the Search pill so users can read a single URL or PDF without also turning on web_search.
  • Adds a Fetch pill to the chat composer with its own persisted toggle (webFetchToolsEnabled / unsloth_chat_web_fetch_tools_enabled in localStorage). The pill is hidden on providers that don't ship web_fetch.
  • Defaults Fetch off so per-fetch billing is always a deliberate opt-in.

Why

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.

Implementation

  • chat-runtime-store.ts: new webFetchToolsEnabled boolean (persisted via loadBool / saveBool on CHAT_WEB_FETCH_TOOLS_ENABLED_KEY), matching supportsBuiltinWebFetch capability, setWebFetchToolsEnabled setter, plus reset entries in resetExternalModelState.
  • chat-page.tsx: both runtime-bootstrap sites compute supportsBuiltinWebFetch from providerSupportsBuiltinWebFetch and hydrate webFetchToolsEnabled from stored localStorage (default off).
  • chat-adapter.ts: webFetchEnabledForThisTurn now sources from webFetchToolsEnabled instead of toolsEnabled.
  • shared-composer.tsx: Fetch pill rendered next to Images, only when supportsBuiltinWebFetch. Disabled when no model is loaded.
  • provider-capabilities.ts: comment updated to reflect the standalone pill.

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. Coverage already pinned in studio/backend/tests/test_anthropic_web_fetch.py at test_web_fetch_tool_appended_to_request_body (standalone-only) and test_web_fetch_combined_with_web_search_and_code_execution (combined).

Test plan

  • npx tsc --noEmit in studio/frontend/ (clean).
  • Manual chat with Anthropic Claude 4.7: Fetch pill alone forwards enabled_tools=[\"web_fetch\"] and the model can read a URL without web_search being enabled.
  • Manual chat: Fetch + Search both on forwards [\"web_search\", \"web_fetch\"] and behaves identically to the pre-change combined path.
  • Manual: Fetch pill is hidden on OpenAI / Gemini / OpenRouter / Kimi / DeepSeek / Mistral.
  • Refresh chat: Fetch toggle survives reload (localStorage round-trip).

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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa074e8bde

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 906 to 910
const webFetchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
webFetchToolsEnabled &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
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 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request decouples the 'Web Fetch' tool from the 'Web Search' tool, introducing a standalone 'Fetch' pill in the chat composer. The changes span the chat runtime store, the chat adapter for request construction, and the UI components to manage the independent state and persistence of the fetch tool. A review comment identifies a critical oversight where the disabledToolGuard logic was not updated, which may cause the system prompt to incorrectly inform the model that it lacks fetch capabilities even when the tool is enabled.

Comment on lines 906 to 911
const webFetchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
webFetchToolsEnabled &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
);
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.

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.
@danielhanchen
Copy link
Copy Markdown
Member Author

Pushed 9d2b0c2 addressing the codex P1 and gemini High on the disabled-tool guard.

Confirmed the gap: after introducing webFetchEnabledForThisTurn, the guard branch at chat-adapter.ts:954 still only inspected 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 enabled_tools=["web_fetch"] we send and suppresses live web_fetch invocations.

Fix: treat search and fetch as a single "any web tool enabled" axis (anyWebEnabledForThisTurn = webSearchEnabledForThisTurn || webFetchEnabledForThisTurn) and key the guard branches on it. 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 picks the right one from the tool schema. The existing webLabel already covers both names, so the user-visible text stays accurate in every Search/Fetch/Code combination.

tsc clean. Re-confirmed backend translation is unchanged (test_anthropic_web_fetch already pins standalone + combined paths).

danielhanchen and others added 4 commits May 23, 2026 19:34
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.
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.

@danielhanchen
Copy link
Copy Markdown
Member Author

Round 4 cross-cutting fix: merged origin/main into this branch (no conflicts) to bring in PR #5735 (orphan tool_call XML strip widening + 263-line test_tool_xml_strip.py). All 8 PRs in this audit cohort had been forked off a pre-#5735 main, so a squash-merge of any of them would have silently reverted the widened _TOOL_XML_RE regex and deleted the dedicated test file. Verified: diff against origin/main now shows zero unintended changes to routes/inference.py and test_tool_xml_strip.py outside the actual PR scope.

@danielhanchen danielhanchen merged commit 7d3c472 into main May 26, 2026
34 checks passed
@danielhanchen danielhanchen deleted the feat/anthropic-web-fetch-standalone branch May 26, 2026 06:37
rhsCZ pushed a commit to rhsCZ/unsloth that referenced this pull request May 26, 2026
Main moved forward 17 commits during PR review (latest: 953c8bf). Real
conflicts in five files; resolved by combining both branches' changes.

studio/backend/core/inference/external_provider.py
- Add fast_mode (Anthropic Opus 4.6/4.7 speed flag, unslothai#5715) to
  stream_chat_completion and Anthropic-branch call site, alongside
  existing Gemini tools/tool_choice forwarding.
- Add _openai_image_generation_tool() helper (action:"edit" for follow-
  up image edits, unslothai#5712) and use it inside the existing
  _responses_hosted_builtins_allowed gate so the forced-function /
  tool_choice="none" suppression added in rounds 21+ still applies.
- Keep Anthropic web_fetch gated on _anthropic_hosted_builtins_allowed
  (round 19+ hosted-builtin gate) while taking main's per-model
  version selector (web_fetch_20260209 vs _20250910).

studio/backend/routes/inference.py
- Add `openai = provider_type == "openai"` (used by main's reasoning
  content forwarding for follow-up image edits).
- Keep the round 25/26 Gemini filter chain (_filter_tool_calls drops
  synthetic server-builtin cards, marks tc_id so the matching
  role="tool" follow-up gets skipped, extra_content gated to native
  Gemini host).
- Forward fast_mode alongside tools/tool_choice.

studio/backend/tests/test_openai_image_generation.py
- Combine assertions: both _server_tool: True (PR) and
  openai_image_generation_call_id (main) are present on the tool_start
  arguments.

studio/frontend/src/features/chat/shared-composer.tsx
- Add supportsBuiltinWebFetch declaration (separate Fetch pill from
  unslothai#5742) before the PR's isExternalGemini constant so both the Gemini
  image-tier gating and the standalone Anthropic Fetch pill compile.

studio/frontend/src/features/chat/api/chat-adapter.ts
- Add main's normalizeOpenAIReasoningItem, toOpenAIImageEditReferenceMessage,
  isAnthropicRefusalMessage helpers alongside PR's collectAssistantToolCalls,
  collectToolResultMessages, SerializedMessage, collectAssistantTextThoughtSignature.
- toOpenAIMessages (PR) now also early-returns on isAnthropicRefusalMessage
  so refused turns get pruned from outbound history.
- Add a thin toOpenAIMessage (singular) wrapper for the OpenAI image-
  edit replay path's flat .map() usage.
- Merge per-turn enable flags: keep PR's imageGenerationEnabledForThisTurn,
  geminiImageModeForThisTurn, codeExecEnabledForThisTurn !geminiImageMode
  gate; take main's webFetchEnabledForThisTurn (sourced from independent
  webFetchToolsEnabled pill state).
- Outbound build chains main's anthropic_refusal survivingMessages prune,
  then flatMap(toOpenAIMessages) (PR), then PR's selectedImageEditReference
  reference message prepend; image-edit unavailable toast from main fires
  before any of that when the pill is off.
- tool_end merge: do main's nextArgs spread first, then PR's Gemini
  native_part parts concat so both OpenAI image-call ids and Gemini
  executableCode/codeExecutionResult/inlineData round-trip.
- Cumulative + final yields: orderAssistantContent(pinTextThoughtSignature(...))
  composes main's tool-vs-text ordering with PR's per-text thoughtSignature pin.

Tests: gemini provider 148/148; openai_responses_translation + openai_code_execution
+ openai_image_generation + anthropic_code_execution + anthropic_web_fetch +
external_provider_usage_chunk + providers_api: 50 passed, 42 skipped; main's
new anthropic_fast_mode + citations + openai_citation_markers + openai_tool_result_fallbacks
suites all 43/43.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant