Repository navigation
design server tools
Issue #56, shipped 2026-10-15. Research basis:
research-provider-model-params.md (§3.1 upstream history, §4.2 the replace
defect, §5 the candidate design this implements). Everything here is about
the platform assistant and the provider REGISTRY form; no client-agent
writer changed.
buildGatewayModel (#51) mapped serverTools to samplingParams.tools.
On the openai-responses wire pi-ai builds the converted client tools first
(params.tools) and runs Object.assign(params, samplingParams) LAST
("so … sampling parameters override named request fields") — a shallow merge
that REPLACED the tools array. The assistant sends its AgentTools every turn
(M2 MCP bridge + read_skill), so any registry entry with serverTools set
stripped them all. The #51 S0 spike verified the seam existed, not the merge
semantics.
Upstream (checked 2026-10-15): pi proposals #9556/#9560 (Model.serverTools,
append-verbatim on BOTH openai-responses and anthropic-messages wires), #7704
(builtinTools), #8661 (reserved-name translation) were all auto-closed by
the repo's contribution gate and never reopened; no released pi-ai (1.1.0 /
1.2.0) or master carries server-side tool support. Their APPEND semantics are
the reference we implement ourselves.
server/src/assistant/provider.ts:
-
appendServerTools(body, serverTools, kind)— pure: for a chat-shaped body (hasinputormessages) appends the server-tool entries tobody.tools(creating the array when absent); anything else returns untouched. -
serverToolsFetch(serverTools, kind, inner)— wraps a fetch; wheninit.bodyis JSON and chat-shaped, rewrites it viaappendServerToolsbefore the inner fetch; non-JSON / non-chat pass through byte-identical. -
streamFninjects the wrapper ONLY for kindsopenai-responsesandanthropic, wrapping a caller-provided fetch when present. Both pi-ai adapters handoptions.fetchto their OpenAI/Anthropic SDK client — one seam every wire crosses. ThesamplingParams.toolsmapping is GONE;topP/extraBodystill ridesamplingParams(OpenAI wires only).
Per kind:
-
openai-responses: bare{ type }entries verbatim. -
anthropic(2026-10-16 follow-up, Bailian's anthropic-compatible doc — the rig's actual route): aweb_search*type also carriesname: "web_search"(Bailian's setup shape; official Anthropic discriminates ontypeeither way), AND the body gains a LEADING system text blockx-anthropic-billing-header: cc_entrypoint=cli;— the marker Claude Code itself sends; Bailian's gateway silently SKIPS web search without it ("需在 system 中传入客户端标识…否则联网搜索不生效"). String system prompts are normalized to blocks. On official Anthropic both additions are inert. Wire-level compat only — no prompt/product surface, registry value shape untouched.
Value shape unchanged: serverTools: string[], WYSIWYG wire values. The
anthropic preset IS the dated type (web_search_20250305) — no alias map,
no request-build translation. openai-chat has no tools[] surface → not
injected, warned.
SERVER_TOOL_PRESETS / EXTRA_BODY_PRESETS / EXTRA_BODY_BOOLEAN_KEYS in
packages/shared (re-exported by the SDK):
| api kind | serverTools presets | extraBody key presets |
|---|---|---|
| openai-responses |
web_search, web_extractor, code_interpreter
|
enable_thinking |
| anthropic | web_search_20250305 |
— |
| openai-chat | — (field hidden; search rides extended params) |
enable_search, search_options, enable_text_image_mixed, enable_thinking
|
Open lists by design (the catalog evolves server-side); typed custom values in the combobox are the gateway-compat escape hatch (future dated aliases, vendor-specific names). On openai-chat the extraBody field is the PRIMARY carrier for Bailian-style search, not merely an escape hatch.
enable_thinking on the responses wire was doubted and then LIVE-VERIFIED
(#58, 2026-10-16): Bailian's official Responses parameter table documents it
top-level, and the endpoint accepts and honors it — qwen3.8-flash,
non-streaming: enable_thinking:true → output items ["reasoning", "message"], false → ["message"] only. The same doc marks it deprecated
in favor of reasoning.effort ("enable_thinking 后续将不再支持") — when
Bailian drops it, drop the preset; reasoning effort rides pi's first-class
reasoningEffort, not extraBody. (The Responses table also lists MORE
built-in tools than our presets — web_search_image, image_search,
file_search, mcp — presets stay at the curated baseline; typed values
cover the rest.)
providerModelConfigWarnings: serverToolsIrrelevant now fires for
api === 'openai-chat' ONLY (both other wires carry server tools);
extraBodyIrrelevant unchanged (anthropic).
New kit device TagCombobox (components/kit/tag-combobox.tsx): a
creatable multi-select — chosen values render as removable × tags, the input
filters the preset list, a typed value is offered as a custom row
("Use “{value}”"), Enter commits the typed value verbatim, Backspace on an
empty input drops the last tag. Two consumers (serverTools, extraBody keys)
earn it its kit place.
Provider form (pages/LlmProviders.tsx):
- #58-2 hide-and-clear: serverTools renders ONLY on openai-responses and anthropic (the kinds with a tools[] surface); extraBody ONLY on the OpenAI-compatible kinds. Switching the api kind WIPES the now-hidden field from every dialog row — a saved payload never carries values the wire cannot use (the shared warnings stay as the REST backstop; #56's "values survive a kind switch" behavior is superseded).
- extraBody keys via TagCombobox, then a per-key value editor — a Switch for
EXTRA_BODY_BOOLEAN_KEYS, a JSON Input otherwise. Invalid/empty JSON drops that key at the save boundary, flagged inline (per-rowinvalidnote), never saved. -
#58-1: the dialog's fetch-candidates no longer mass-adds rows — the
model-id input is a combobox offering the fetched list (see
design-provider-model-registry.md§3.1).
- shared 118 / server 343 / cli 315 / web 107 green; ui-snap 52/52; web build clean.
- New tests:
server/test/assistant-server-tools.test.tspins append-not-replace, untouched non-chat bodies, wrapper composition;web tag-combobox.test.tsxpins presets/custom/remove/filter/keyboard; shared warning + preset-table tests. - agent-browser on the docker rig (deployed web verified by md5 + marker):
per-kind preset lists (anthropic
web_search_20250305; openai-chat extraBody keys + hint + the narrowed warning; openai-responses web_search/web_extractor/code_interpreter), custom row, boolean toggle, api-switch tag retention, save → server payload exact (serverTools: ["web_search_20250305","web_search"],extraBody: { "enable_thinking": false }), edit round-trip.
-
Bailian anthropic-wire CoT flattening (2026-10-16, wire-isolated via
direct endpoint matrix): the precise trigger is STREAMING + the
cc_entrypointbilling marker — with both, Bailian's Claude-Code-mode pipeline returns DeepSeek reasoning as inline TEXT blocks (no thinking_delta at all); non-streaming separates thinking under every shape tested. The marker cannot be dropped: without it web search silently does not execute (verified — no server_tool_use blocks).nameand mixed client+server tools are NOT triggers (S5/S7 clean). So on the anthropic wire it is a genuine either/or: search (marker required) or separated CoT. Bailian's Responses wire gives BOTH: streaming +web_searchexecutes real searches (web_search_call.*events) AND keeps reasoning separated (response.reasoning_text.delta). The workaround for CoT-sensitive routes: a provider row withapi=openai-responseson…/compatible-mode/v1instead of the anthropic-compatible endpoint. (Product-side isolation first showed "serverTools on → CoT in body, off → thinking blocks"; the raw-wire matrix pinned the actual switch.) - Per-entry extra fields (
max_uses) and the reserved-name translation (#8661) remain out of scope — MCP bridge tools are<server>__<tool>- prefixed, so only custom tool names can collide. - The anthropic temperature/thinking gate warning (research §4.1) is a separate follow-up, untouched here.
- Bailian's doc lists supported web-search models (Qwen3.x, glm-5.2, kimi-k3, deepseek-v4-pro/v4-flash-0731) WITHOUT deepseek-v4.1-flash — the rig verified v4.1-flash DOES execute search (2026-10-16); the doc list lags.
- Found while verifying: the assistant hub's
liveflag clears ONLY on an explicitassistant:session.close— an abruptly closed tab leaves the session live (and thus undeletable, #55's 409 guard) until a server restart; no socket-disconnect eviction exists. Filed as its own issue (#57).
The rig's real route: Bailian's anthropic-compatible endpoint
(token-plan…/apps/anthropic), model deepseek-v4.1-flash,
serverTools: ["web_search_20250305"], assistant hikari. First attempt
failed (the server container had not been redeployed with #56 — anthropic
wire injected nothing; the model answered "no web tool"). After the
redeploy + the name/billing-marker follow-up: a current-events question
returned live facts with cited URLs (multi-round search visible in the
reasoning), 19s / ↑23k ↓1.1k tokens. Test session deleted; the account's
pre-existing session untouched.