Skip to content

refactor(proxy): rewrite server tools around the declared tool type - #163

Merged
orangeboyChen merged 9 commits into
mainfrom
fix/rewrite-server-tool-flow
Sep 18, 2026
Merged

orangeboyChen merged 9 commits into
mainfrom
fix/rewrite-server-tool-flow

Conversation

@orangeboyChen

@orangeboyChen orangeboyChen commented Sep 17, 2026

Copy link
Copy Markdown
Owner

The bug

Claude Code declares WebSearch as an ordinary function tool and resolves it itself. The proxy classified server tools by name, and normalizeToolName strips case and separators — so WebSearch and web_search compared equal:

WebSearch   -> websearch
web_search  -> websearch   equal? true

When the model returned a WebSearch tool call, the proxy executed the search itself and answered it. Claude Code never received the tool_use block it was waiting for, so the search it asked for never happened and the turn ended with an answer invented from memory.

The fix

Only the declared type distinguishes them. Anthropic sends web_search_20250305, Responses sends web_search_preview, and a client's own function is declared as function or with no type. Classification now reads the type and never the name, and both translators preserve it through to the request that goes upstream.

The flow, as the spec describes it

Phase 1 — the main request. Claude Code declares WebSearch. The proxy forwards it as an ordinary function and returns an Anthropic tool_use. It does not search — Claude Code resolves it itself.

Phase 2 — the side request. Claude Code sends a second, independent request carrying the server type. Now the proxy searches, and it does so with a real loop:

while model requests web_search:
    execute search
    append tool result
    call chat again

bounded by the max_uses the client declared. The loop is over server tools only — a call to the client's own WebSearch is never picked up.

Phase 3 — the continuation. Claude Code wraps the answer as a WebSearch tool_result and continues; the proxy does not re-enter the server-tool path.

What went away

web-search-loop.ts (924 lines), the server-tool/ module, and the codebuddy/server-tools.ts stream probe — replaced by server-tools/ (~700 lines). The private x-codebuddy2api-server-tool marker went with them: it carried the conflated identity, and nothing reads it now.

Net −9,295 lines.

Verification

  • bun run lint, format:check, typecheck, build — clean
  • 853 tests; 94.09% statements / 94.65% lines / 94.57% functions
  • test:patch-branches90.74%

Reviewed by four agents per round, across four rounds

Each round dispatched 2–4 agents to audit the code against the 26-chapter spec and report deviations; findings were fixed and re-audited until clean. Worth knowing what that found, because several defects were ones I introduced while fixing earlier ones:

  • a fetch-only turn looped forever (the budget counter ignored fetches)
  • clamped calls left unpaired tool_calls in the transcript, so upstream rejected the next hop after the searches were already billed
  • the budget was read per tool kind but spent as a disjunction, so exhausting searches withdrew fetches and the fetch allowance was unreachable
  • the respelling fallback was registered but never consulted — dead code
  • that fallback and the collision test contradicted each other: making the collision test exact re-enabled the fallback, reintroducing the original bug. They are now separate gates — an exact test decides whether to disable the feature, a normalised one decides whether respelled names are safe to claim
  • on Responses, the preamble rendered after the searches, and findIndex picked the preamble as the answer to stream, so the real answer got no deltas
  • twice I added a parameter and forgot the call site (pinnedResponseId, then emitOpeningEvents), producing two response ids and then two response.created events

Test coverage note

An early audit found most tests bypassed the real translation path — they fed hand-built declarations straight into rewriteServerTools, so mapAnthropicToolsToChat was never exercised and a translator bug that dropped max_uses was invisible. Tests now go through handleMessagesRequest / handleResponsesRequest with the spec's actual payload shape, including max_uses and tool_choice.

Also

vitest.config.ts now excludes .worktrees/** — a git worktree is a full checkout carrying its own copy of the suite (and, once built, a second under .next/standalone), which was being collected and run against the current tree.

Classification matched a server tool by name, and `normalizeToolName`
strips case and separators — so `WebSearch`, the ordinary function Claude
Code declares and resolves itself, compared equal to `web_search`, the
provider-executed server tool. The proxy answered Claude Code's own
calls, the `tool_use` block it was waiting for never arrived, and the
turn ended with an answer invented from memory.

Only the declared type can tell them apart. Anthropic sends a dated type
(`web_search_20250305`), the Responses API sends `web_search_preview`,
and a client's own function is declared as `function` or with no type at
all. Classification now reads the type and never the name, and both
translators preserve it through to the request that goes upstream.

That removes the ambiguity the loop existed to paper over. A request
either declares a server tool — in which case one search runs and
upstream is asked once more, without the tools, to write the answer —
or it does not, and every tool call goes back to the client untouched.
So `web-search-loop.ts` and the `server-tool/` module go away, replaced
by `server-tools/`, and each route drives the turn itself instead of it
hanging off the shared chat chokepoint.

The private `x-codebuddy2api-server-tool` marker goes with them: it was
the mechanism that carried the conflated identity, and nothing reads it
any more.

Also excludes `.worktrees/**` from vitest, which was collecting a whole
stale copy of the suite out of every git worktree.
Raises changed-branch coverage to 91.75%, past the 90% floor.

`rewriteServerTools` can only be reached once declarations have been
found, which already guarantees a non-empty array, so its nullable
return was unreachable — it now takes `unknown[]` and the caller drops
the guard. The unused `readJsonResponse` helper goes too.

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

Copy link
Copy Markdown

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: aa808206e4

ℹ️ 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 thread lib/server/proxy/anthropic.ts
Comment thread lib/server/proxy/server-tools/turn.ts Outdated
Comment thread lib/server/proxy/responses/event-stream.ts Outdated
Comment thread lib/server/proxy/server-tools/turn.ts Outdated
Comment thread lib/server/proxy/server-tools/turn.ts Outdated
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.78313% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.81%. Comparing base (45227c5) to head (7413a3e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #163      +/-   ##
==========================================
- Coverage   95.73%   94.81%   -0.93%     
==========================================
  Files          70       67       -3     
  Lines        6708     6436     -272     
  Branches     1927     1864      -63     
==========================================
- Hits         6422     6102     -320     
- Misses        286      334      +48     
Flag Coverage Δ
unittests 94.81% <95.78%> (-0.93%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Server tools are now answered by a genuine loop, bounded by the `max_uses`
the client declared, instead of a fixed two-call turn:

    while model requests web_search:
        execute search
        append tool result
        call chat again

The loop is over *server* tools only. Claude Code's own `WebSearch` is an
ordinary client function and a call to it is never picked up — that is the
distinction the whole feature turns on, and the reason classification keys
off the declared type rather than the name.

- Translation carries the whole declaration through, so `max_uses` reaches
  the loop (it was dropped before, leaving the budget pinned at 5).
- The budget is per tool kind: a fetch's allowance no longer caps searches.
- Calls are clamped to the budget before running, and the transcript only
  carries calls that were actually executed, so no assistant message ever
  promises a result it does not have.
- A server tool nothing here can run is withdrawn rather than offered;
  offering it handed the client a `tool_use` it had no handler for.
- Every exit reports the searches already run and the usage accrued,
  including when a later hop fails.
- On Responses the preamble lands ahead of the searches, and the stream
  carries one id, with the answer — not the preamble — as text deltas.
The `web_fetch` end-to-end probe raced a real local fetch behind the mocked
endpoint call — `local-fetch` uses `node:http`, so the stub the setup file
installs on `globalThis.fetch` did not apply and the test resolved a real
hostname. That is why it passed locally and failed on CI, and it bought
nothing: the branches it targeted are not in this change's diff, so patch
coverage is unmoved.

`test:ci` now also runs the default reporter alongside the JUnit one. The
JUnit reporter writes failures to a file rather than stdout, which is how a
red CI run ended up with no explanation in the log.
@orangeboyChen

Copy link
Copy Markdown
Owner Author

@codex review

@orangeboyChen

Copy link
Copy Markdown
Owner Author

@codex Please validate whether the flow complies with Anthropic's server-side tool specification.

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

Copy link
Copy Markdown

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: ddf9604ebb

ℹ️ 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 thread lib/server/proxy/anthropic.ts Outdated
Comment thread lib/server/proxy/server-tools/turn.ts Outdated
Comment thread lib/server/proxy/anthropic/stream.ts Outdated
Comment thread lib/server/proxy/anthropic/stream.ts Outdated
orangeboyChen and others added 5 commits September 18, 2026 10:19
Addresses the Codex review findings on the PR.

Prose written *between* two searches was dropped. A single preamble can
only hold the first hop's, so a turn like
text → search → text → search → answer lost the second passage entirely.
The turn now reports one segment per hop — the prose that preceded it
plus the calls it made — and every renderer interleaves them the way
Anthropic does, instead of gathering prose and blocks by kind.

Also from the review:

- Streamed preambles went out whole inside `content_block_start`. Streaming
  consumers read generated text from `text_delta` / `thinking_delta`, so a
  block that starts already complete reaches no delta callback at all.
  They are now emitted as an empty start, a delta, then a stop.
- A streamed `server_tool_use` carried the full input *and* streamed it
  again as an `input_json_delta`. Anthropic builds streamed input from
  deltas alone, so the block now opens with `input: {}`.
- Withdrawing a server tool nothing here can run left a forced
  `tool_choice` naming it, which an upstream validating the two together
  can reject instead of letting the model answer. A forced choice is now
  dropped whenever the tool it names is not on offer.
The contract these routes are audited against: the three-phase flow, and
the distinction between `WebSearch` (Claude Code's own client tool, which
the proxy must never pick up) and `web_search_*` (a provider-executed
tool the proxy must run, in a loop bounded by `max_uses`, hiding the
internal chat function).

Kept out of the VitePress navigation: it is an internal design document
rather than user documentation, and the guide tree is localised per
language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nning

Conformance fixes against the design spec in docs/design/claude-code-websearch-flow.md.

- Terminate the loop when a hop can afford nothing. `spent` tested "every kind
  exhausted" while affordability is per kind, so with both search and fetch
  runnable, a model that kept asking for the exhausted one ran a hop that
  executed nothing: the transcript never advanced and the loop re-issued an
  identical billed request forever.
- Stop handing the internal search function to the client on the failure
  paths. Both error returns passed the payload through verbatim, so a
  `web_search` tool_call could reach the client as a `tool_use` for a tool it
  never declared and has no handler for. Every return now keeps only the calls
  the client can answer.
- Read the closing call's body through the same guard every other hop uses. An
  unparseable body threw out of the turn, discarding every search already run
  and billed.
- Reconcile `tool_choice` against the tools actually offered, on the first hop
  and on the closing call: a pin can no longer name a withdrawn server tool,
  and a pin the client set on one of its own tools survives the withdrawal.
- Judge name collisions per server-tool kind, so a clash on the fetch name no
  longer withholds a search the client also declared.

Responses path:
- Accumulate image-loop iteration segments instead of keeping the first, which
  dropped every search after the first along with the prose that preceded it.
- Skip call items the proxy mints when replaying a transcript; each was
  becoming an empty user turn, one per search the previous turn ran.

Tests cover each fix, plus the three gaps the spec leans on hardest: both
tools declared in one request with the model calling both, the query coming
from the model's arguments rather than the prompt, and the Anthropic
translator preserving a declared server tool's type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects on the Responses path, both confirmed against the real handler
rather than inferred.

A hosted-tool `tool_choice` was rejected outright: `{type: 'web_search_preview'}`
matches none of the three shapes the validator accepts, so pinning search
returned 400. Past validation it would have failed anyway — the choice was
forwarded unchanged as a type the chat upstream has never seen, while the
declaration itself had been rewritten into an ordinary function. The pin is
what makes the model emit a query instead of answering from memory, so it is
now rewritten to name the function the proxy actually injects. With no backend
configured the pin is dropped rather than naming a withdrawn tool, and the
request answers from memory instead of failing.

The image-generation loop replayed each round without the turn's searches. The
turn builds its continuation against a transcript it keeps to itself, so the
replayed request asked the model to continue from input in which the search it
had just run did not exist:

    round 1: [user, assistant[web_search], tool]
    round 2: [user, assistant[image_generation], tool]   <- search gone

The turn now hands back the messages it appended, on the same out-of-band
channel it already used for executions, and the loop splices them in ahead of
its own message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A client that hung up mid-turn was charged for the rest of the budget it
declared. The loop had no way to know, so every remaining search ran, each
followed by an upstream round trip, and the closing answer was written for
nobody. The turn now takes an `AbortSignal` and stops at the next checkpoint
— between hops, before executing, and before the closing call — handing back
499 (client closed request) with the searches already run still attached, as
on every other non-success path. It does not abort mid-hop: an upstream call
already in flight cannot be recalled more cheaply than letting it finish.

`rebuildResponse` now labels what it returns as JSON rather than inheriting
the upstream's content-type. Every caller hands it a `JSON.stringify(...)`
result, so an upstream that answered a `stream: false` hop with SSE left a
body claiming to be an event stream.

Responses path:

- Hosted-tool `tool_choice` now covers the tools this adapter actually serves.
  `image_generation` is executed here, so pinning it 400'd; it names the
  injected function instead. A pin on a declared-but-unimplemented hosted type
  is dropped rather than rejected, so the request is served without the tool
  instead of failing. Pinning a tool the request never declared still 400s.
- `output_text` carries `url_citation` annotations for the search results the
  model actually quoted. The indices are measured, never guessed: a search
  knows the titles and URLs but nothing about where they land in an answer
  that did not exist yet, so a result whose URL never appears in the text gets
  no annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@orangeboyChen
orangeboyChen merged commit 61ef6a6 into main Sep 18, 2026
7 checks passed
@orangeboyChen
orangeboyChen deleted the fix/rewrite-server-tool-flow branch September 18, 2026 05:20
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