From 71c9003bd8144f3b8c364b5184fd40b90057ebfb Mon Sep 17 00:00:00 2001 From: ArthurErlich Date: Sun, 6 Sep 2026 16:10:26 +0200 Subject: [PATCH] Create dashscope-websearch-selfhost-options.md --- .../dashscope-websearch-selfhost-options.md | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/research/dashscope-websearch-selfhost-options.md diff --git a/docs/research/dashscope-websearch-selfhost-options.md b/docs/research/dashscope-websearch-selfhost-options.md new file mode 100644 index 0000000..89cab43 --- /dev/null +++ b/docs/research/dashscope-websearch-selfhost-options.md @@ -0,0 +1,412 @@ +# Research: self-hosted alternatives to DashScope for Qwen Code's built-in `web_search` tool + +**Question:** Qwen Code CLI's built-in `web_search` tool requires `tools.webSearch.model` +to resolve to a "DashScope-compatible `modelProviders` entry." Is there any real, +non-Alibaba-Cloud way to satisfy that requirement with something self-hosted — +or is the already-working OmniRoute MCP + SearXNG path (`docs/research/omniroute-qwen-websearch.md`) +the end of the road? + +**Answer, short version:** No. The client-side code that decides whether a +`baseUrl` is "DashScope-compatible" checks the **literal hostname** against a +hardcoded allowlist of Alibaba-owned domains, before any request is sent — it +is not a protocol-compatibility check that a look-alike server could pass. A +self-hosted server cannot satisfy it, full stop, unless you fork qwen-code and +delete that check. Once you've done that, the actual wire protocol +(OpenAI SDK `responses.create()`, SSE, specific item types) is buildable +(a few hundred lines), but nothing you can install off the shelf implements it +today. The already-working OmniRoute MCP + SearXNG path costs nothing further +and does not have this problem. **Recommendation: don't build this — see +§6.** + +## 1. What "DashScope Responses API" is, precisely + +Alibaba Cloud Model Studio (Bailian/DashScope) added an **OpenAI-compatible +Responses API**, layered on top of its existing Chat Completions +compatible-mode surface: + +- Endpoint (per Alibaba's own docs): `POST {baseUrl}/responses`, where + `baseUrl` is the region's compatible-mode base, e.g. + `https://dashscope.aliyuncs.com/compatible-mode/v1` (China/Beijing) or the + `-intl` / regional `*.maas.aliyuncs.com` variants. + Source: https://www.alibabacloud.com/help/en/model-studio/qwen-api-via-openai-responses + and https://www.alibabacloud.com/help/en/model-studio/compatibility-with-openai-responses-api +- Request shape: standard Responses API (`model`, `input`, `stream`, `store`, + `instructions`) plus a `tools` array that can include + `{"type": "web_search"}`, `{"type": "web_extractor"}`, `{"type": "code_interpreter"}` + as **hosted, server-side tools** — the inference backend runs the search + itself and streams results back, the same hosted-tool pattern as OpenAI's + own Responses API `web_search_preview`, not a client-side function-calling + round trip. + Source: https://www.alibabacloud.com/help/en/model-studio/qwen-api-via-openai-responses +- Response shape: an `output` array of typed items — a `web_search_call` item + carries `action: {type, query, sources: [{type: "url", url}]}`; narration + comes back as `message` items with `content: [{type, text}]`. + Source: same page. +- Separately, DashScope's plain Chat Completions endpoint (not Responses) + has an older, unrelated `enable_search` boolean (passed via `extra_body`) + for models like Qwen3.8/Qwen3.6-Plus — the docs explicitly note this older + surface **does not return citations/sources**, which is exactly why + qwen-code's built-in tool uses the *Responses* API instead. + Source: https://docs.qwencloud.com/developer-guides/tool-calling/web-search + +This is the same hosted-tool pattern as OpenAI's Responses API +`web_search_preview` (§4 confirms this directly from qwen-code's own client +code — it literally reuses the OpenAI Node SDK's `responses.create()` call +against a DashScope base URL). + +## 2. What qwen-code's own client code actually sends (ground truth) + +Fetched directly from `QwenLM/qwen-code`'s `main` branch, +`packages/core/src/tools/web-search.ts` (1087 lines) and +`packages/core/src/core/openaiContentGenerator/{constants,provider/dashscope}.ts`. +This supersedes anything inferred from the docs pages — it's the literal +implementation. + +**The request** (`web-search.ts` lines 651–699): + +```ts +const client = new OpenAI({ + apiKey, // from the resolved modelProviders entry's envKey + baseURL: backend.baseUrl, // the modelProviders entry's baseUrl / WEB_SEARCH_BASE_URL + timeout: 60_000, + defaultHeaders: { 'User-Agent': `QwenCode/${version} (...)`, ...customHeaders }, +}); + +const tools = [{ type: 'web_search' }]; +if (backend.webExtractor) tools.push({ type: 'web_extractor' }); + +const requestParams = { + model: backend.modelId, + input: `Perform a web search for the query: ${query}`, + stream: true, + store: false, + instructions: SIDE_REQUEST_INSTRUCTIONS, // a fixed system prompt, see source + tools, +}; + +const stream = await client.responses.create(requestParams, { signal }); +``` + +This is the **official OpenAI Node SDK**, so `client.responses.create()` +literally POSTs to `{baseURL}/responses` with that JSON body and reads back +an SSE stream — there is no DashScope-specific SDK involved at all. Anything +speaking real OpenAI Responses-API SSE syntax at that path, with these two +extra tool types, is protocol-compatible. + +**What the client parses out of the SSE stream** (lines 359–509): event types +`response.output_item.added`, `response.output_item.done`, +`response.output_text.delta`, and terminal `response.completed` / +`.failed` / `.incomplete` / `.cancelled`, each carrying a `response` object +with `output: WsOutputItem[]` and `usage.x_tools.{web_search,web_extractor}.count`. +Output items it understands: `web_search_call` (`action.query`/`action.queries`, +`action.sources[].url`, `status`), `web_extractor_call` (`urls`, `goal`, +`output`, `status`), and `message` (`content[].text`). It also defensively +handles a DashScope-specific quirk: **request-level failures arrive as a bare +SSE `event:error` with `{code, message, request_id}` and no `type`/`error` +wrapper** — the OpenAI SDK doesn't recognize this shape, so qwen-code parses +it itself (comment: "probe-verified"). Any replacement backend needs to emit +exactly these item/event shapes, or qwen-code's parser silently ignores +unrecognized item types and ultimately reports +`WEB_SEARCH_NO_SEARCH_PERFORMED` (it treats zero `web_search_call` items as +"no search happened," with one retry, before failing outright — see lines +883–906). + +**The hard gate — this is the actual finding.** Before any request is sent, +`evaluateWebSearchGate()` (lines 169–335) validates the resolved `baseUrl` +through `classifyDashScopeBaseUrl()` (lines 122–157): + +```ts +function classifyDashScopeBaseUrl(baseUrl: string): DashScopeBaseUrlIssue | null { + const url = new URL(baseUrl); + if (url.protocol !== 'https:') return 'insecure'; + const hostname = url.hostname.toLowerCase(); + const suffixes = [ + ...DASHSCOPE_REGIONAL_HOSTS, // dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, dashscope-us.aliyuncs.com + 'maas.aliyuncs.com', + 'alibaba-inc.com', + 'aliyun-inc.com', + ]; + return suffixes.some(s => hostname === s || hostname.endsWith('.' + s)) ? null : 'unknown-host'; +} +``` + +`DASHSCOPE_REGIONAL_HOSTS` is defined in +`packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` as +exactly `['dashscope.aliyuncs.com', 'dashscope-intl.aliyuncs.com', 'dashscope-us.aliyuncs.com']`. + +**This means "DashScope-compatible" is not a protocol claim you can satisfy +by implementing the right JSON shapes — it is a literal hostname allowlist +checked client-side, before the request is even built.** A self-hosted server +at `http://search.home`, `https://proxy-ai.home`, or any hostname you control +will be rejected with *"WebSearch ... is not a DashScope-compatible +endpoint"* regardless of what protocol it speaks, unless its hostname ends in +one of `dashscope.aliyuncs.com`, `dashscope-intl.aliyuncs.com`, +`dashscope-us.aliyuncs.com`, `*.maas.aliyuncs.com`, `*.alibaba-inc.com`, or +`*.aliyun-inc.com` — domains Alibaba owns, that you cannot obtain a valid TLS +certificate for. (There's also a separate, unrelated `DASHSCOPE_PROXY_BASE_URL` +env var used by the *main* content generator's provider-detection code +(`dashscope.ts` lines 244–262) for header/cache-control routing through a +corporate proxy — it is not consulted by `classifyDashScopeBaseUrl()` at all, +so it does not help here either.) + +The only way around this specific check is to **fork qwen-code and delete or +relax `classifyDashScopeBaseUrl()`** — it's ~15 lines of open-source +TypeScript, so this is not hard *code-wise*, but it means running a patched +build of the CLI, not configuring the stock release. + +## 3. Any self-hostable server implementing this surface today? — No + +Checked the servers this task named: + +- **vLLM**: has a real `/v1/responses` implementation + (https://docs.vllm.ai/en/stable/api/vllm/entrypoints/openai/responses/), and + for `gpt-oss` models specifically supports a **built-in `browser` tool** + with a pluggable, MCP-compliant external tool server in place of the + default Exa-backed reference implementation + (https://vllm.ai/blog/2025-08-05-gpt-oss; + https://github.com/vllm-project/recipes/blob/main/OpenAI/GPT-OSS.md). This + is the closest existing building block found — but it's gpt-oss/harmony + specific (not Qwen), and its tool/event shapes (`browser.search`, + `browser.open` harmony-channel messages) are **not** the same as DashScope's + `web_search_call`/`web_extractor_call` items qwen-code's parser expects, so + it is not drop-in — it would need a translation shim in front, at which + point you're building the shim anyway and don't need vLLM in the path. +- **SGLang**: Responses API support is unclear/inconsistent per its own + issue tracker (https://github.com/sgl-project/sglang/issues/10038) — no + usable built-in web-search tool found. +- **LiteLLM**: does expose `/v1/responses`, but has an **open bug** + rejecting the `web_search` tool type outright — "LiteLLM raises a + validation error... only `web_search_preview` is currently allowed" + (https://github.com/BerriAI/litellm/issues/14011). Its actual SearXNG + integration is the unrelated standalone `/v1/search` REST endpoint already + documented in `docs/research/litellm-searxng-search.md` (§1–3 there) — a + sibling API to chat/responses, not a Responses-API `tools:[{"type":"web_search"}]` + handler. It doesn't have a DashScope-mode either + (https://docs.litellm.ai/docs/providers/dashscope is a plain client wrapper + that calls the real dashscope.aliyuncs.com; nothing in it hosts a + DashScope-shaped server). +- **LocalAI / Ollama**: no Responses API or DashScope-compatible mode found + in searches for either. +- **A generic "OpenAI Responses API" self-hosted shim that could be relabeled**: + the closest match found, `teabranch/open-responses-server` (185 stars, 161 + commits, wraps Ollama/vLLM as a Responses API with MCP support), **does not + implement `web_search` at all** — its own roadmap lists "Web search: crawl4ai" + as a *future* item, not shipped (verified live against the repo, + 2026-09-05). No other candidate turned up in repeated GitHub searches for + "dashscope emulator/mock/fake server" or "responses api web_search + self-hosted." + +**Conclusion for §3: nothing installable off the shelf implements the +DashScope Responses API's `web_search`/`web_extractor` hosted-tool surface.** +Building it means writing your own small SSE server (see §5 sizing). + +## 4. Is DashScope's shape "OpenAI Responses API + web_search" reused wholesale? + +Yes, confirmed directly from source, not inference: qwen-code's client uses +the **official `openai` npm package**'s `client.responses.create()` against a +DashScope `baseURL` (§2 above) — it is not a DashScope-specific SDK or +protocol. OpenAI's own Responses API supports a hosted `web_search_preview` +tool with a similar `output[].type === "web_search_call"` item shape +(OpenAI's public Responses API docs, referenced but not independently +re-fetched here since qwen-code's source is authoritative for what it +actually calls). DashScope's extension is the tool *name* (`web_search` +rather than `web_search_preview` — the exact naming mismatch LiteLLM's own +open bug in §3 stumbles on) plus the additional `web_extractor` tool and the +`x_tools` usage-accounting field. No existing "OpenAI Responses API shim" +project was found that already emulates `web_search_preview`/`web_search` +server-side against a pluggable backend (see §3) — the two hosted-tool +ecosystems (OpenAI's and DashScope's) both currently require literally +calling out to the vendor's own cloud; nobody has open-sourced a +self-hosted stand-in for either. + +## 5. LiteLLM specifically, re-examined against this exact requirement + +`docs/research/litellm-searxng-search.md` already established SearXNG is a +first-class LiteLLM `search_provider` behind the **standalone** `/v1/search` +REST endpoint (its own §1–2). That endpoint is irrelevant to qwen-code's +`tools.webSearch.model` gate: qwen-code doesn't call an arbitrary search REST +endpoint, it calls `POST {baseUrl}/responses` on an **OpenAI-SDK client** +with `tools:[{type:"web_search"}]`, and gates `baseUrl` on the Alibaba +hostname allowlist in §2. Even ignoring the hostname gate entirely (i.e. +assuming a patched qwen-code build), LiteLLM's `/v1/responses` route +currently **rejects** the `web_search` tool type per the open bug in §3 — so +today, LiteLLM cannot terminate this request even as an internal component of +a custom build. Nothing here changes the litellm-searxng-search.md +recommendation; it remains correct and unrelated to this question. + +## 6. Effort assessment and recommendation + +**Option A — patch qwen-code + hand-roll a DashScope-Responses-shaped SSE +server in front of SearXNG.** What it needs, concretely: +1. Fork qwen-code, delete/relax `classifyDashScopeBaseUrl()` (§2) — trivial, + but means building and distributing a patched CLI, and re-patching on every + upstream update that touches this file or its surrounding gate logic. +2. Write a small HTTP server exposing `POST /responses` that: accepts the + exact request shape in §2, calls SearXNG (`http://search.home`, already + reachable per `docs/research/litellm-searxng-search.md`'s `extra_hosts` + finding) for results, and streams back SSE events in the precise sequence + qwen-code's parser expects (`response.output_item.added` / + `.done` with a `web_search_call` item carrying `action.sources[].url`, + optionally a `message` item with narrated text, then + `response.completed`). No narration/LLM step is strictly required — an + empty or templated `message` still satisfies the parser as long as at + least one non-`failed` `web_search_call` item exists (§2's "no-search" + check only counts search-call items, not narration quality). + Realistically a few hundred lines (Node/Python + SSE), a day or so of + work plus debugging the exact event ordering, error-shape (`event:error` + quirk), and `store`/`instructions` fields the client sends but doesn't + strictly require echoing back. +3. Register this server's URL as a `modelProviders` entry — except the + patched hostname check from step 1 is required for step 3 to pass at all, + so steps 1 and 2 are both mandatory, not alternatives. +4. Maintain the fork indefinitely against upstream qwen-code releases. + +**Option B — do nothing further.** `docs/research/omniroute-qwen-websearch.md` +already documents a **verified, working, fully self-hosted** path: OmniRoute's +own `omniroute_web_search` MCP tool, backed by this stack's SearXNG instance, +confirmed connected (`qwen mcp list` → Connected) and exercised end-to-end +(`POST /v1/search` returned real results). This uses qwen-code's *documented, +supported, unpatched* MCP-server extension point (`mcpServers` in +`settings.json`) — no fork, no upstream-drift risk, no protocol shape to +maintain. + +**Recommendation: do not build Option A.** The built-in `web_search` tool's +"DashScope-compatible" requirement is, by design in qwen-code's own source, a +hostname allowlist for Alibaba's cloud — it is not a compatibility surface +meant to be reimplemented, and no one else has reimplemented it either (§3). +Satisfying it self-hosted requires forking and permanently maintaining a +patch to code whose only purpose is to *stop* you from doing that. The MCP +path in `omniroute-qwen-websearch.md` already delivers the same end-user +capability (web search, backed by this stack's own SearXNG, no external +API) through qwen-code's actual supported extension point, with zero ongoing +fork-maintenance burden. There is no functional gap Option A would close that +Option B doesn't already close today. + +## Open questions / unknowns + +- Whether `DASHSCOPE_REGIONAL_HOSTS` or the extra suffixes + (`maas.aliyuncs.com`, `alibaba-inc.com`, `aliyun-inc.com`) ever change + across qwen-code releases — checked only against the current `main` branch + (fetched 2026-09-05); a future release could tighten or loosen this list. +- Whether OpenAI's own `web_search_preview` Responses-API tool has a + publicly documented exact request/response JSON schema identical enough to + DashScope's `web_search`/`web_extractor` pair that a single shim could serve + both — not independently verified against OpenAI's own docs in this pass; + qwen-code's source (§2) is authoritative for the DashScope side only. +- Whether `teabranch/open-responses-server`'s planned "Web search: crawl4ai" + roadmap item, if shipped, would end up emitting DashScope-shaped + `web_search_call` items or OpenAI-shaped `web_search_preview` ones — could + become relevant later but is speculative (unshipped) as of this research. + +## Sources + +- https://qwenlm.github.io/qwen-code-docs/en/developers/tools/web-search/ and + https://raw.githubusercontent.com/QwenLM/qwen-code/main/docs/developers/tools/web-search.md + — current built-in-tool vs. MCP options, settings keys, migration note. +- `packages/core/src/tools/web-search.ts`, + `packages/core/src/core/openaiContentGenerator/constants.ts`, + `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` — + fetched directly from `QwenLM/qwen-code`'s `main` branch via + `raw.githubusercontent.com` on 2026-09-05; ground truth for the request + shape, SSE parsing, and the hostname gate (§2). +- https://github.com/QwenLM/qwen-code/issues/3841 — prior (closed, + "not planned") community proposal for DashScope `enable_search` passthrough; + shows the feature that eventually shipped took a different path (Responses + API, not Chat Completions `enable_search`). +- https://www.alibabacloud.com/help/en/model-studio/qwen-api-via-openai-responses + and https://www.alibabacloud.com/help/en/model-studio/compatibility-with-openai-responses-api + — Alibaba's own Responses API docs: endpoint, `tools` shape, `output` item + shape. +- https://docs.qwencloud.com/developer-guides/tool-calling/web-search — + the older Chat-Completions `enable_search` mechanism and its + no-citations limitation. +- https://docs.vllm.ai/en/stable/api/vllm/entrypoints/openai/responses/, + https://vllm.ai/blog/2025-08-05-gpt-oss, + https://github.com/vllm-project/recipes/blob/main/OpenAI/GPT-OSS.md — vLLM's + Responses API and gpt-oss browser-tool/tool-server support. +- https://github.com/sgl-project/sglang/issues/10038 — SGLang Responses API + support unclear. +- https://github.com/BerriAI/litellm/issues/14011 — LiteLLM's `/v1/responses` + rejects the `web_search` tool type. +- https://docs.litellm.ai/docs/providers/dashscope — LiteLLM's DashScope + provider is a plain client wrapper, no Responses API, no web_search. +- https://github.com/teabranch/open-responses-server — closest + "self-hosted Responses API shim" found; web_search not implemented + (roadmap item only), checked live 2026-09-05. +- `G:\_DEV\repos\LLM-Server\docs\research\omniroute-qwen-websearch.md` — + the already-working, verified self-hosted alternative this doc is weighed + against. +- `G:\_DEV\repos\LLM-Server\docs\research\litellm-searxng-search.md` — + LiteLLM's actual (unrelated) SearXNG integration, re-confirmed as + orthogonal to this question in §5. + +## Tried it live (2026-09-05) — confirmed empirically, plus one new fact + +The user asked to actually run the experiment rather than stop at the analysis above. + +**What was done** (all local to the WSL install, reverted afterward — nothing in +this repo or the live OmniRoute instance was left changed): +- Patched the installed CLI file + `~/.local/lib/qwen-code/lib/chunks/web-search-K2FMOGS5.js` with a one-line + bypass in `classifyDashScopeBaseUrl()`: `if (baseUrl.includes("proxy-ai.home")) return null;` +- Added a `tools.webSearch` block to `~/.qwen/settings.json` pointing + `model`/`baseUrl` at a new `qwen-experiment-websearch` `modelProviders` entry + using OmniRoute's existing `http://proxy-ai.home/v1` and the already-working + `OMNIROUTE_API_KEY`. +- Ran `qwen` with a prompt forcing use of the built-in `web_search` tool. + +**Result — the client-side gate bypass worked**, confirming the research's +read of `classifyDashScopeBaseUrl()` was accurate: qwen accepted the OmniRoute +host as "DashScope-compatible" and attempted the tool call. It stopped at an +interactive approval prompt first (expected — headless auto-approve wasn't +attempted, since that flips on unrestricted auto-execution of every tool call +at process privilege, not just this one). + +**New fact, not visible from static docs alone**: a direct `curl -X POST +http://proxy-ai.home/v1/responses` (with a valid key, matching the request +shape qwen would send) returned `{"error":{"message":"No active credentials +for provider: codex.","type":"authentication_error","code":"invalid_api_key"}}` +— **not** the generic "unknown route" error a nonexistent path returns (verified +earlier in this same research thread against `/v1/search`-adjacent bogus +paths). So `/v1/responses` **is a real, implemented OmniRoute route**, not +merely undocumented — the earlier inference that it didn't exist was wrong; +it exists but is hardcoded to proxy exclusively through a specific provider +connection OmniRoute's catalog calls `codex`. + +**`codex` identified via `PROVIDER_REFERENCE.md`**: `id: codex`, alias `cx`, +name "OpenAI Codex", **auth type: OAuth** — a real, personal +ChatGPT/OpenAI-account connection, not a free/no-auth scraper provider like +several others already connected in this instance (`felo-web`, +`duckduckgo-web`, etc.). Checked `docs/reference/ENVIRONMENT.md` for any +setting to redirect `/v1/responses` to a different provider — **none +exists**; there is no `responsesProvider` or equivalent override. + +**Why routing isn't configurable, architecturally**: OpenAI's Responses API +`web_search` is a *hosted* tool — the search executes inside the model +backend's own infrastructure as part of generating the response, not as a +client-visible round trip. Confirmed directly against llama.cpp's own +`tools/server` docs (`github.com/ggml-org/llama.cpp/tree/master/tools/server`): +it implements only `/v1/chat/completions` with client-side tool-calling +(the model emits a `tool_call`; the *client* must execute it), has no +`/v1/responses` endpoint, no hosted-tool execution, and its built-in +`--tools` are local-only (`read_file`, `grep_search`, `exec_shell_command`, +etc.) — none make outbound HTTP requests. So even with configurable routing, +pointing `/v1/responses` at the local Qwen model wouldn't work: the upstream +llama-server has nothing that could serve the hosted-tool half of the +contract. Building that would mean OmniRoute (or a custom shim) intercepting +the model's tool-call mid-generation and splicing in a real search — the +same shim work priced out as not-worth-it earlier in this document, now +confirmed to be the *only* way, not one option among several. + +**Conclusion holds, sharpened**: the dead end isn't just qwen-code's +client-side hostname check anymore — even a fully self-hosted, hostname-gate-bypassed +setup terminates at OmniRoute's `codex`-only `/v1/responses` routing, which +itself terminates at needing a real OpenAI/ChatGPT OAuth account, which is +exactly the kind of external paid dependency this whole line of inquiry was +trying to avoid. `omniroute_web_search` via MCP (already working, already +free, already self-hosted) remains the only path that actually satisfies the +original goal. + +**Revert**: both the CLI patch and the `settings.json` changes were reverted +after the test — `omniroute-search` MCP confirmed still `Connected` via +`qwen mcp list` afterward. No lasting changes from this experiment.