feat: select a Web-search backend on the Provider form - #412
Draft
MesoX wants to merge 3 commits into
Draft
Conversation
added 3 commits
August 3, 2026 16:34
Makes the Web-search backend extension point (ADR-0014) reachable without the API, and presents a plugin backend's results the way native search's are. - `GET /organizations/:orgId/web-backends` lists the backends registered at boot, each annotated with the plugin that contributed it. Org-scoped rather than workspace-scoped, unlike sandbox's `GET /backends`: a Provider can be org-scoped (ADR-0007) and one form serves both scopes, so a workspace-scoped catalog would be unreachable when an Org Admin edits a Shared Provider. Readable by any org member, matching `GET /backends`, because a non-admin Workspace Owner may edit a Provider when `providerSelfManagement` is set. - The Provider form gains a Web-search backend select under Advanced settings, shown for every Provider type. It stays interactive while Native web search is off and warns instead — disabling it would trap a stored value behind the switch. A stored id the catalog no longer lists is shown as "not installed" rather than reading as None. - The Chat search toggle now appears when a Provider has native search *or* a configured backend, which is ADR-0014's gate formula. Gated on the stored id alone; a stale id degrades server-side. - A plugin `web_search` is client-executed, so its citations are lifted into the same `<Sources>` row native `source-url` parts use, and its tool card is compact rather than the generic raw-JSON block (ADR-0014 rejects the blob). Every URL is re-checked with `isPresentableUrl` before it becomes an href. `isPresentableUrl` moves to `@platypus/schemas`, deferred to this PR when the frontend became its second consumer: two copies of a security predicate drift, and the frontend's is the one that decides what becomes a link. Docs: the extending page no longer says selection is API-only, chat.mdx's toggle-visibility bullets are true again, and `concepts/providers.mdx` gains the section the field lands on.
Native provider search registers under the same `web_search` tool name as a Web-search backend's (`services/provider.ts` — OpenAI, OpenRouter, Anthropic), so the tool name alone cannot identify a plugin result. Discriminating on the name meant a native search rendered the new compact card: "0 results" for a search that returned ten, with the vendor payload no longer shown. One `isPluginWebSearchPart` predicate now gates both the card and the Sources lifting on `providerExecuted !== true`, and both callers share it rather than matching `startsWith` in one place and `===` in the other. A native search keeps the generic renderer and cites through its `source-url` parts. Alongside, in the same surface: - The Sources row deduplicates plugin results against the native URLs the same message already cites, so a page named by both is one pill and counts once. - The card says "Searching…" until an output exists, instead of reporting 0 results for a call still in flight. - The Provider form distinguishes a catalog that answered from one that failed: the field stays when `GET /web-backends` errors and says the list may be incomplete, and a stored id is only called "not installed" once the catalog has actually been read. - The backend selector's description states that Native web search gates it, and that switch's own description no longer recommends turning it off on the endpoints this feature exists for without naming the exception. - The Trigger form's Web Search switch no longer says "model native web search" — a Trigger on a Provider with a backend searches through it. - `humanizeToolType` splits underscores, so `web_search`, `read_url` and most MCP tools stop reading as "Web_search". - The catalog is sorted by display name in the form; the route returns registration order, which follows `PLATYPUS_PLUGINS`. - `isPresentableUrl` is no longer re-exported from `web-backends/`; callers import it from `@platypus/schemas`, where its cases are tested. Docs: the Bedrock case for the Native web search switch, and the Trigger step's Web Search wording.
…vider flag
`providerExecuted` alone cannot separate a plugin `web_search` from a
Provider's own. It is each provider package's field to set: Anthropic and
OpenAI set it from the first chunk, but `@openrouter/ai-sdk-provider` never
sets it anywhere, so a native OpenRouter search still reached the compact
card — and after the "Searching…" fix it would sit there permanently rather
than claiming "0 results".
Core owns the Tool it builds, so it marks it. `composeWebBackend` stamps
`metadata: { platypusWebBackend: true }` on both Tools; the AI SDK carries a
Tool's `metadata` onto the tool call's `toolMetadata`, onto the UI part, and
into the stored message, so nothing a provider executes can carry it on any
vendor. The key is exported from `@platypus/schemas` for `isPresentableUrl`'s
reason — the frontend reads what the backend writes — and pinned by a backend
test on the side that writes it.
`isPluginWebSearchPart` is now layered by how much each check knows:
`providerExecuted === true` excludes a native call; the marker identifies
core's; core's own result shape covers a finished call stored before the
marker existed; and only `input-streaming`, where no marker is attached yet
and there is no output to read, falls back to the flag. Anything left over
reads as native and keeps the generic renderer, which shows whatever it
really is.
Alongside, in the same surface:
- The card claims a search is in flight only for the two states where one is.
A denied call and an `output-error` without `errorText` previously read as
"Searching…"; the header already reports both.
- Sources deduplication moved to the render site, and the plugin entry now
wins a collision — it carries the backend's title, where a `source-url`
part has only the URL. Its comment no longer claims to cover a history
spanning a Provider change: the dedup is per message, and one turn resolves
to a backend or to native search, never both.
- Where the Native web search switch is not rendered (Bedrock), the warning
beside the selector names the Provider API instead of pointing at a control
that is not on screen. `concepts/providers.mdx` matches, rather than
promising search runs there unconditionally.
- A test for `humanizeToolType`, including the namespaced MCP names its
underscore split exists for.
Contributor
Author
|
@willdady Hi, I would love to get some input here, before I mark this ready for review. Thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR3 — Catalog route, Provider-form selector,
canSearch, Sources renderingCloses the operator-facing half of #329. Third in the stack behind #384 (PR1) and #393 (PR2). Branch
feature/web-backend-catalog-and-selectoroffmain@f48fa24.Until now a Web-search backend could only be selected by
PUTingwebBackendon the Provider API, and on a Provider with no native search the Chat Search toggle stayed hidden — so the endpoints this Extension point exists for (vLLM, LiteLLM, Bedrock) could not reach a backend from Chat at all. This makes the selection reachable and presents a backend's results the way native search's are.20 files, +1373/−80. No migration, no schema change, no change to what a searching turn resolves to.
What's in it
GET /organizations/:orgId/web-backends—{ results: { backend, name, plugin }[] }fromgetWebBackends()+getWebBackendPlugin.Mounted org-scoped, which is a deliberate correction to "mirror sandbox's
GET /backends". A sandbox is a workspace resource; a Provider is not — Providers exist in both scopes (ADR-0007) and oneProviderFormserves both from anorgIdplus an optionalworkspaceId. A workspace-scoped catalog is unreachable exactly when an Org Admin edits a Shared Provider, which is the case the selector matters most for. The precedent isGET /plugins, mounted the same way and commented for the same reason: the orgId scopes access, not the data.No scope filtering is possible or needed: a backend arrives via
PLATYPUS_PLUGINSin the backend environment and is registered at boot into a module-level registry. There is no table behind it, so no org or workspace can hold a different list.Provider form — a nullable select under Advanced settings, beside the Native web search switch, fed by the route through SWR. Shown for every Provider type, including ones with native search, since a set backend wins over native by design.
canSearch— nownativeSearchEnabled !== false && (providerHasNativeSearch(p) || Boolean(p.webBackend)), which is ADR-0014's gate formula verbatim. Gated on the stored id alone: no catalog fetch inchat.tsx, no livenesssome()(that is PR9). A stale id degrades server-side to no tools plus a warn line.Sources rendering — native search surfaces citations as
source-urlparts. A pluginweb_searchis client-executed, so without lifting its result the same toggle gives pills on Anthropic and nothing on vLLM. Itsresults[].url/titlenow merge into the one<Sources>row, deduplicated by URL.Four decisions worth your attention
1. The
web_searchtool card is compact, not the generic renderer. Adding pills alone would have given a vLLM user pills and a collapsible raw-JSON block repeating every result, where Anthropic shows pills alone — which is the outcome ADR-0014's "Presenting results" section rejects. Sotool-web_searchgets its own branch besideLoadSkillTool: header with the query, body with the answer box and a result count, no JSON. It stays rather than being hidden entirely because the pills cannot say that a search ran, what was searched for, or that it failed — and a failed search returns{ error }on a successful part, so there is nothing in the Sources row to notice.read_urlkeeps the generic card; it is a fetch, not a citation source.2. The select is not disabled while Native web search is off. The plan's wording was "reads as disabled/inert", but a genuinely
disabledselect traps a stored backend: clearing it would mean switching native search on, clearing, and switching off again. Concealing a stored value is the same category of trap this section exists to close, so the control stays interactive and the helper text turns into a visible warning instead. Same reasoning behind showing a stored id the catalog no longer lists, labelled(not installed), rather than hiding the field on a deployment with no backends when a value is set.3.
isPresentableUrlmoved to@platypus/schemas— which reverses the answer you gave on #384's approval ("stays inweb-backends/"). That answer deferred the decision to this PR, on the condition that the frontend becomes the second consumer, which it now is; recording the reversal rather than letting it read as drift, as with PR2's two-warn fold. The frontend needs exactly the scheme filter — length is bounded upstream byMAX_URL_CHARS, with the opposite treatment (drop, not truncate) — so it is lifted verbatim rather than mirrored. Two copies of a security predicate drift, and the frontend's copy is the one deciding what becomes anhref. Nothing inapps/frontendimports backend runtime code, so "leave core's alone" would have meant a mirrored predicate, not a shared one. The re-export fromweb-backends/is gone and every caller imports from the package; the cases live inpackages/schemas/index.test.ts.4. Telling a plugin
web_searchfrom the Provider's own. They share a tool name:services/provider.tsregisters native search underweb_searchon OpenAI, OpenRouter and Anthropic. Matching the name alone — the first shape of this PR — sent a native search to the compact card, which claimed "0 results" for a search that returned ten and hid the vendor payload. OneisPluginWebSearchPartpredicate now gates both the card and the Sources lifting, layered by how much each check knows:providerExecuted === true⇒ native. Definitive where a provider sets it; Anthropic and OpenAI both do, from the first chunk, so there is no flicker.composeWebBackendsetsmetadata: { platypusWebBackend: true }on both Tools it builds, and the AI SDK carries a Tool'smetadataonto the tool call'stoolMetadata, onto the UI part, and into the stored message. This is the only check that does not depend on a vendor reporting itself — and it is needed, because@openrouter/ai-sdk-providernever setsproviderExecutedanywhere in the package. OnproviderExecutedalone, a native OpenRouter search would have kept the original bug, in a worse form: no output to count, so a permanent "Searching…".input-streamingalone falls back toproviderExecuted: a marker is attached when the tool call is parsed, and there is no output to recognise yet.Anything left over reads as native and keeps the generic renderer, which shows whatever it really is. I could not verify OpenRouter empirically — no account — so the fix is written not to need the answer: the marker is true of core's tools on every provider, verified or not. The marker key is exported from
@platypus/schemasfor decision 3's reason, and pinned by a backend test on the side that writes it.This is a dependency ADR-0014 does not record. Its Citations consequence says the frontend reads the core-owned
results[].url; it must also decide whichweb_searcha part is before reading that shape at all. Queued as a sixth Consequences amendment for PR4, together with the cost it names: the deliberate snake_case exception buys prompt portability by sharing native search's tool name, and the price is that core cannot be identified by name.The render-side check is not redundant with the backend-side drop. The egress guard covers model-supplied URLs entering
read_url; this covers backend-supplied URLs becoming clickable, which nothing else covers, and a backend is third-party code.javascript:anddata:results are dropped, and they are dropped before the Sources count too — a "Used 3 sources" row that opens onto one pill is its own bug.Exercised against a real backend
Core ships no backend by design (ADR-0014 non-goal), which makes this stack hard to try. So I wrote one, out of tree and public, to have something to point a Provider at: https://github.com/MesoX/platypus-searx-backend
It is a plain
webBackendscontribution —web_searchover a self-hosted SearXNG,read_urlover obscura via CDP — so no search API key and no third-party account are needed to exercise the Extension point end to end.read_urlis contributed only when a browser URL is configured, which also makes the optional-second-tool path a real case rather than a test fixture. The README carries the compose snippets and the two non-default SearXNG settings it needs.I am not proposing it as a dependency or asking for it to be adopted — it is a reference implementation and a test harness. It happens to be the first third-party consumer of the contract shipped in #384/#393, which is what makes it useful here: everything in this section was found by using the seam, not by reading it.
It works. A self-hosted deployment on a test box, Provider
Local(OpenAI +apiMode: "chat", the vLLM shape), backendsearx.web: search, citations, paginated reading, the egress block and the stale-selection degrade all behave as specified. The scenario-by-scenario account, with the signal that distinguishes each one and an explicit list of what the manual pass did not cover, is under Verification → Real-world verification below.Two things came out of running it that a code review will not surface, below.
PR3.5 is a real dependency of this one, not a nice-to-have
This PR ships two controls fighting over one slot — a
nativeSearchEnabledswitch and awebBackendselect, where the switch is a master off for both. That arrangement has no good version, and the state table shows why:nativeSearchEnabledwebBackendRow 4 is the trap: an Operator who switched native search off on a vLLM Provider because native search never worked there selects a backend and finds it silently dead. Flipping row 4 the other way does not fix it — it makes the switch native-only and therefore inert whenever a backend is selected, a control whose effect depends on the value of the control beneath it.
The end state is one three-valued control,
searchSource: none | native | <backendId>:That is PR3.5, promoted out of the optional follow-up list. It carries the migration (
search_source+ backfill, old columns kept one release), theresolveSearchModerewrite, the form collapse, and the ADR Consequences edit. ~15 files.Why it is not folded into this PR: that would put a data migration with a backfill, a change to what a searching turn resolves to, and a new frontend feature carrying a security check (the
javascript:pill filter) into one review of ~22–25 files — and you split #384 at a much finer grain than that. The selector here is not throwaway: under PR3.5 it is still a<Select>fed by this route through the same threeformDatatouch points, so most of it survives; what changes is the option list and the bound field. The Switch it replaces already exists onmain.If you would rather have the collapse now and skip the interim shape, say so — that is the main reason this is a draft.
Found while testing: turn-level token growth (not this PR's bug)
Worth recording because the measurement only exists because this stack made searching reachable. On the test box, one searching turn on an Agent reached 229,677 input tokens across 15 steps (7
web_search, 11read_url) and died on the step ceiling without answering; another reached 67,688 in 9. Everyread_urlresult — 5 000–10 000 chars — stays in the transcript and is re-sent on every subsequent step, so growth is quadratic in reads.Not web-search-specific and not a regression: a Trigger run with
fetchUrl×2 andupdateWidgetData×9 and no search at all hit 187,066. Core's per-call caps already bound what one call contributes; what is unbounded is what is retained. The portable fix is clearing stale tool results from the transcript (deleting, not summarising — the two compose, and a cleared result is visibly gone where a summary is lossy in a way the model cannot detect). I would file it against the compaction workstream rather than this stack unless you want it here. Happy to open an issue with the numbers if useful.Open questions
requireAuth+requireOrgAccess(), matchingGET /backends, notGET /plugins' admin-only gate. Admin-only would break a real case: a workspace-scoped Provider is editable by a non-admin Workspace Owner whenproviderSelfManagementis set, and that owner would get an empty dropdown with no explanation. The cost is that any org member can learn which search plugins the deployment has installed, whichpluginsrestricts to admins. The two existing precedents already disagree with each other, so converging them is above this PR — but the choice should be visible rather than inherited silently. Tell me if you want admin-only and I will take the empty-dropdown case instead.nativeSearchEnabledrelabel is deferred, not dropped. chore(docs): correct the Web-search backend Extension point page #401's out-of-scope note assigns it here. It is deferred because the field disappears in PR3.5, and relabelling a control that is about to be deleted means writing user-facing copy twice and the docs twice on the same surface. The interim mitigation is one sentence at the point of selection plus the visible warning. Say so if you would rather have the relabel now.isPresentableUrlin@platypus/schemasreverses your feat: add the web-search backend Extension point #384 answer, on the condition that answer named (a second consumer). Flagging rather than assuming.One correction to the switch's own description did ship, which my plan had ruled out along with the relabel. Its description read "Turn off for endpoints that don't implement it (e.g. vLLM, Ollama, LiteLLM)" — after PR2 that instructs an Operator to disable the backend on precisely the endpoints this Extension point exists for. That is shipped text walking a user into the trap, not the cosmetic relabel, so I treated the "nothing else" line as drawn one clause too wide and added the exception to the sentence. Still no relabel.
Two related findings are deliberately not fixed here, since they die with the collapse: the
providerType !== "Bedrock"condition hiding the switch now hides a control that gates a Bedrock Provider's backend (not a live bug — the column isnotNull().default(true), so only a deliberate API write reaches that state), and the switch is still shown on Providers where it is inert.What did get fixed is the new warning pointing at a control that is not on screen: where the switch is not rendered, the note beside the select names the Provider API as the only way into and out of that state. The condition is untouched; a warning this PR introduces should not create the dead end.
Docs
Three of the four are mandatory because this PR makes shipped text false:
extending/index.mdxsaid "Selection is not on the Provider form yet", and the warning callout under it was built on that premise. Both rewritten; the callout now carries only the coupling that survives.building-with-platypus/chat.mdxlisted Bedrock and OpenAI-in-chat-mode as reasons the toggle is absent. Both are now conditional on there being no backend.building-with-platypus/triggers.mdxdescribed the Trigger's Web Search switch as "Model native web search (if supported by provider)". A Trigger'ssearchreaches the sameresolveSearchMode, so on a Provider with a backend it searches through it.concepts/providers.mdxgains a new## Web searchsection — the page where Provider fields are documented, and there is nobuilding-with-platypusProviders page for CLAUDE.md's table to point at. chore(docs): rewrite the docs for the reader, not the schema #392 stripped every search mention from it, so this is new rather than an amendment. It states the Bedrock case exactly as the form does: the switch is not shown because it is on by default there, and if it was switched off through the API the selection still will not run.self-hosting/providers-and-auth.mdxis untouched: this PR adds no field limit or env var that page carries. ThewebBackend200-char bound and the backend-author caps stay with PR4'sextending/web-search-backends.mdx, where a plugin author reads them — a length limit beside a dropdown nobody types into is noise.Verification
Automated gates
pnpm typecheckandpnpm lintclean workspace-wide.pnpm test: backend 1387, frontend 97, schemas 103, plugin-sdk 7, examples 3 — all passing, re-run on the branch tip againstmain@f48fa24with nothing behind.apps/docshas one pre-existing failure indocs-contract.test.ts's internal-links case, identical on a cleanmain(23 pass / 1 fail both ways) — a CRLF artifact of running it on Windows, where the heading regex (^#{1,6}\s+(.*)$) keeps a trailing\r, so every anchor in the tree mis-slugs and the whole check goes empty rather than failing on anything this PR wrote.git config core.autocrlfistruehere and the index is LF, so Linux CI is unaffected. Both anchors this PR adds (/concepts/providers#web-search,/extending#web-search-backends) were checked by hand and resolve to real headings. Making the test CRLF-tolerant is a standalonechore, not this stack's.Real-world verification, against a backend written for this PR
The automated suite mocks the executor, so none of it proves the seam works against third-party code. To have something real to point a Provider at, I wrote a backend out of tree and made it public: https://github.com/MesoX/platypus-searx-backend — a plain
webBackendscontribution,web_searchover self-hosted SearXNG andread_urlover obscura via CDP. No search API key and no third-party account, so the whole Extension point is reproducible on any box. It is the first third-party consumer of the contract shipped in #384/#393; everything in the "four decisions" section above was found by using that seam, not by reading it.Deployment under test: a self-hosted box running the full stack off this branch, Provider
Local= OpenAI +apiMode: "chat"(the vLLM shape — a Provider with no native search, i.e. exactly the case this Extension point exists for), backend idsearx.web, both services on the compose network. Backend logs tailed on the observability line ({ backend, plugin, tool, durationMs, outcome }) throughout.Checked by hand, each with a signal that distinguishes it from every other case:
<Sources>row of ≤10 pills where SearXNG returned 24–26 raw hits, so core'sMAX_SEARCH_RESULTSslice is visibly running rather than the backend's raw outputread_url, short pageread_url, long pagemax_length[Content truncated. Pass start_index=5000 to continue reading.]andnext_start_index: 5000— byte-identical tofetchUrl, so a model that learned one tool pages the other correctlyread_urlPLATYPUS_PLUGINS, Provider unchangedsearx.web (not installed)rather than a blank select; a searching turn injects no tools and warns withproviderIdNot covered by the manual pass, stated so it is not read as more than it is. This was a basic-scenarios run, not the full matrix: the failure paths (SearXNG down, obscura down, a slow page hitting
outcome: "timeout"rather thanerror), the boot-refusal paths (a mis-keyed plugin config, an over-ceilingtimeoutMs), the hostile-backend stub returningjavascript:/data:result URLs, and the over-1M-char content cap were not run against the live deployment — all of them are covered by unit tests instead. Neither was the native-vendor comparison, which needs a second Provider on OpenRouter (no account; see open question 5). Thejavascript:pill filter in particular is proven by frontend tests, not by a live hostile backend.New tests
nullfor an unowned id, empty registry, readable by a non-admin member, 401 unauthenticated, 403 for a non-member, no mutation verb.isPresentableUrlin schemas, including scheme casing.None's suffix follows capability; a stored-but-uninstalled id is named; the coupling warning appears only when the switch is off and the select stays enabled;webBackendround-trips through a save and sendsnull, not"".hreffor ajavascript:ordata:URL; unpresentable results are not counted; URL fallback for a missing title; one pill for a page two searches both returned; merged with nativesource-urlparts; no Sources row on an error; compact card carrying the query instead of the result JSON.source-urlcitations still render; an unflagged, unmarked part — the OpenRouter shape — does the same; a marked part shows the card mid-call; a result stored before the marker still lifts its pills; a denied call claims no search in flight; a page named by both rows renders one pill, titled by the backend.composeWebBackendstamps the marker on both Tools — pinned on the side that writes the seam the rendering reads.humanizeToolType: underscores are word boundaries, soweb_search,read_urland namespaced MCP names stop rendering as "Web_search".One rename not called out in its commit message, for completeness: the form's "no backend" sentinel went from
__none__to__platypus_no_web_backend__, since a backend id is an arbitrary string and a contribution registering the old value would have read as None. Never persisted — it is mapped to""at the control's edge.