From f7880453e2da0d68ef2d195faac9a3c9d0b6da40 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:16:44 -0300 Subject: [PATCH] fix(providers): qwen-web model discovery lists live catalog (#3931) (#4172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qwen-web (cookie provider) had no PROVIDER_MODELS_CONFIG entry, so its model- discovery page returned an empty/stale local catalog β€” the OAuth fallback at the top of the route only fires for provider===qwen, so qwen-web fell through to the no-config branch. Added a qwen-web entry that fetches the public https://chat.qwen.ai/api/v2/models endpoint (no auth header configured/sent) and parses the { data: { data: [{ id, name, owned_by }] } } shape, with a flatter { data: [] } fallback. This is Problem #3 of #3931 (diagnosed by @thezukiru). Problem #1 (validator bare-token false-positive) shipped earlier in the merged PR #3958; Problem #2 (empty stream from Qwen WAF bot-detection on the streaming endpoint) is a separate upstream/stealth concern and stays open. TDD: tests/unit/qwen-web-models-discovery-3931.test.ts mocks the upstream and asserts source==='api' + the live ids (and the flatter shape), RED 0/2 -> GREEN 2/2. Rebaselined route.ts 2512->2531. Co-authored-by: thezukiru --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- src/app/api/providers/[id]/models/route.ts | 19 +++ .../qwen-web-models-discovery-3931.test.ts | 125 ++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/unit/qwen-web-models-discovery-3931.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d427b62b9..eb77367a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _In development β€” bullets added per PR; finalized at release._ ### πŸ› Fixed +- **fix(providers): qwen-web model discovery now lists the live catalog instead of nothing** β€” the `qwen-web` cookie provider had no entry in `PROVIDER_MODELS_CONFIG`, so its model-discovery page returned an empty/stale local catalog (the OAuth fallback at the top of the route only fires for `provider === "qwen"`, leaving `qwen-web` to fall through to the no-config branch). Added a `qwen-web` entry that fetches the **public** `https://chat.qwen.ai/api/v2/models` endpoint (no auth header) and parses the `{ data: { data: [{ id, name, owned_by }] } }` shape (with a flatter `{ data: [] }` fallback). This is Problem #3 of #3931 (diagnosed by @thezukiru); Problem #1 β€” validator bare-token false-positive β€” shipped earlier in #3958, and Problem #2 β€” empty stream from Qwen WAF bot-detection on the streaming endpoint β€” remains a separate upstream/stealth concern. ([#3931](https://github.com/diegosouzapw/OmniRoute/issues/3931) β€” thanks @thezukiru) - **fix(sse): clear error when the request queue drops a job (no more fake-upstream "This job timed out after Nms")** β€” under concurrent load, requests that exceed the per-connection rate-limit queue budget (`resilienceSettings.requestQueue.maxWaitMs`) were dropped by Bottleneck with its raw `This job timed out after ms.` message. That string is indistinguishable from an upstream gateway timeout, so the 502 body and call-log `last_error` looked like a provider outage across unrelated providers (TI:0\|TO:0) β€” an operator spent ~3h misdiagnosing local queue saturation as upstream failures. `withRateLimit` now rewrites that specific Bottleneck error into a clear, OmniRoute-owned message that names the knob (`requestQueue.maxWaitMs`, tunable in Settings β†’ Resilience), explicitly disclaims an upstream timeout, preserves the original as `cause`, and tags `code: "RATE_LIMIT_QUEUE_TIMEOUT"`. Behavior is unchanged β€” the job is still dropped so combo falls back to the next target. ([#4165](https://github.com/diegosouzapw/OmniRoute/issues/4165) β€” thanks @KooshaPari) - **fix(api): advertise the built-in `auto/*` combos in `/v1/models`** β€” OmniRoute ships a zero-setup `auto/*` catalog (`auto/best-coding`, `auto/pro-reasoning`, …, 16 variants) that the dashboard advertises and that resolve on demand, but the `/v1/models` listing only emitted persisted DB combos + provider models. Clients that build their model picker from `/v1/models` (e.g. Hermes Agent) never saw any `auto/*` option. The catalog now emits every `AUTO_TEMPLATE_VARIANTS` id (as `owned_by: "combo"`) at the top of the list, deduped against persisted combos. (Showing each `auto/*`'s dynamically-selected members is a separate enhancement.) ([#4164](https://github.com/diegosouzapw/OmniRoute/issues/4164) β€” thanks @MRDGH2821) - **fix(sse): restore MCP / third-party tool names on the native Claude path (MCP dispatch broken in Claude Code)** β€” since 3.8.27, every MCP tool call routed through OmniRoute to a native Claude OAuth provider failed client-side with `Error: No such tool available: `: tool schemas arrived fine but the streamed `tool_use.name` reached Claude Code in its cloaked form (e.g. `McpN8nMcpSearchWorkflows` instead of the registered `mcp__n8n-mcp__search_workflows`). The native-Claude tool-name cloak stashes its per-request aliasβ†’original map as a **non-enumerable** `_toolNameMap` on the request body; the request-inspector capture added in 3.8.27 rebuilds the captured body from its serialized form (`JSON.parse(JSON.stringify(...))`), which drops non-enumerable properties, so `finalBody._toolNameMap` was empty and the response-side un-cloak silently fell back to the static built-in map β€” never restoring dynamic MCP / snake_case names. Built-in tools (Bash/Read/…) were unaffected (static map); cross-format paths were unaffected (they attach the map enumerably). The provider-request capture now re-attaches the per-request map (kept non-enumerable, so it still never re-serializes upstream) when the captured copy lost it, restoring MCP tool dispatch. ([#4091](https://github.com/diegosouzapw/OmniRoute/issues/4091) β€” thanks @pedrotecinf, @NakHalal) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6d8b967d75..f447ab30c2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_18_3931_qwen_web_models": "Issue #3931 (Problem #3) own growth: providers/[id]/models/route.ts 2512->2531 (+19 = one PROVIDER_MODELS_CONFIG entry for `qwen-web` + a 4-line comment). qwen-web was missing from the config map so its model-discovery page returned nothing (the OAuth fallback only fires for provider===qwen). Pure additive config entry pointing at the public chat.qwen.ai/api/v2/models endpoint; standard per-provider addition, not extractable.", "_rebaseline_2026_06_18_4165_queue_timeout_msg": "Issue #4165 own growth: rateLimitManager.ts 1022->1035 (+13 at the existing withRateLimit catch chokepoint). Bottleneck's raw `This job timed out after ms.` is rewritten into a clear OmniRoute-owned error (names resilienceSettings.requestQueue.maxWaitMs, disclaims upstream, keeps the original as `cause`, tags code=RATE_LIMIT_QUEUE_TIMEOUT) so queue-saturation 502s stop masquerading as provider outages. The branch already existed (it only logged); this adds the error construction at the same point. Not extractable β€” closes over provider/model/maxWaitMs locals of the single catch.", "_rebaseline_2026_06_18_8_2_sliding_window": "Fase 8.2 own growth: rateLimitManager.ts 1017->1022 (+5 = one import + one `await awaitProviderDefaultSlot(...)` call + a 2-line comment at the existing withRateLimit chokepoint). All sliding-window logic was extracted to the new open-sse/services/providerDefaultRateLimit.ts + open-sse/services/slidingWindowLimiter.ts (both 1440 (+5 = appendNoThinkingVariants(finalModels) call + comment at the existing finalModels chokepoint) and chat.ts 1458->1471 (+13 = applyNoThinkingAlias(body) call + comment right after body.model is read, before model resolution). All real logic lives in the new open-sse/utils/noThinkingAlias.ts ( = { authPrefix: "Bearer ", parseResponse: (data) => data.data || [], }, + // #3931: qwen-web (cookie provider) was missing here, so its discovery page + // showed nothing (the OAuth fallback above only fires for provider==="qwen"). + // `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent); + // shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback. + "qwen-web": { + url: "https://chat.qwen.ai/api/v2/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + parseResponse: (data) => { + const innerData = data?.data?.data || data?.data || []; + return (Array.isArray(innerData) ? innerData : []) + .map((item: any) => ({ + id: item.id || item.name, + name: item.name || item.id, + owned_by: item.owned_by || "qwen", + })) + .filter((m: any) => m.id); + }, + }, antigravity: { url: getAntigravityModelsDiscoveryUrls()[0], method: "POST", diff --git a/tests/unit/qwen-web-models-discovery-3931.test.ts b/tests/unit/qwen-web-models-discovery-3931.test.ts new file mode 100644 index 0000000000..99e50840f2 --- /dev/null +++ b/tests/unit/qwen-web-models-discovery-3931.test.ts @@ -0,0 +1,125 @@ +/** + * TDD regression for #3931 (Problem #3, diagnosed by @thezukiru in discussion + * #3895): the `qwen-web` cookie provider had no entry in PROVIDER_MODELS_CONFIG + * (`src/app/api/providers/[id]/models/route.ts`), so the model-discovery page + * returned nothing for it. The OAuth fallback at the top of the handler only + * fires for `provider === "qwen" && authType === "oauth"`, so qwen-web fell + * through to the no-config branch. + * + * (Problem #1 β€” the validator bare-token false-positive β€” was already fixed in + * the merged PR #3958; Problem #2 β€” empty stream from WAF bot-detection on the + * streaming endpoint β€” is a separate upstream/stealth concern, still open.) + * + * Fix: add a `qwen-web` PROVIDER_MODELS_CONFIG entry pointing at the public + * `https://chat.qwen.ai/api/v2/models` endpoint, parsing the + * `{ data: { data: [{ id, name, owned_by }] } }` shape. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-3931-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +interface ModelsBody { + provider: string; + connectionId: string; + models: Array<{ id: string; name?: string; owned_by?: string }>; + source?: string; +} + +const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models"; + +test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "qwen-web", + authType: "apikey", + name: "qwen-web-discovery", + apiKey: "cna=abc; token=def; ssxmod_itna=xyz", + }); + + let fetchedUrl: string | null = null; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + if (u.startsWith(QWEN_WEB_MODELS_URL)) { + fetchedUrl = u; + // Real qwen shape: { data: { data: [ { id, name, owned_by } ] } } + return Response.json({ + data: { + data: [ + { id: "qwen3-max", name: "Qwen3 Max", owned_by: "qwen" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus", owned_by: "qwen" }, + ], + }, + }); + } + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = (await response.json()) as ModelsBody; + assert.equal(body.provider, "qwen-web"); + assert.equal(body.source, "api", "should serve the live qwen-web catalog, not local_catalog/empty"); + assert.ok(fetchedUrl, `should have probed ${QWEN_WEB_MODELS_URL}`); + const ids = body.models.map((m) => m.id); + assert.ok(ids.includes("qwen3-max"), `live ids missing: ${ids.join(",")}`); + assert.ok(ids.includes("qwen3-coder-plus"), `live ids missing: ${ids.join(",")}`); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#3931 qwen-web parseResponse tolerates the flatter { data: [...] } shape", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "qwen-web", + authType: "apikey", + name: "qwen-web-flat", + apiKey: "cna=abc; token=def", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).startsWith(QWEN_WEB_MODELS_URL)) { + return Response.json({ data: [{ id: "qwen-plus", name: "Qwen Plus" }] }); + } + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = (await response.json()) as ModelsBody; + assert.equal(body.source, "api"); + assert.ok(body.models.map((m) => m.id).includes("qwen-plus")); + } finally { + globalThis.fetch = originalFetch; + } +});