fix(providers): qwen-web model discovery lists live catalog (#3931) (#4172)

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 <thezukiru@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-18 11:16:44 -03:00
committed by GitHub
parent fa210de473
commit f7880453e2
4 changed files with 147 additions and 1 deletions

View File

@@ -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 <maxWaitMs> 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: <PascalCaseName>`: 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)

View File

@@ -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 <maxWaitMs> 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 <cap), NOT inlined. Thin wiring only; not further shrinkable.",
"_rebaseline_2026_06_18_8_1_no_thinking_alias": "Fase 8.1 own growth: catalog.ts 1435->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 (<cap); both edits are thin wiring of tested helpers at the single correct integration point in each file. Not extractable.",
@@ -110,7 +111,7 @@
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069,
"src/app/api/oauth/[provider]/[action]/route.ts": 918,
"src/app/api/providers/[id]/models/route.ts": 2512,
"src/app/api/providers/[id]/models/route.ts": 2531,
"src/app/api/providers/[id]/test/route.ts": 842,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1440,

View File

@@ -430,6 +430,25 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
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",

View File

@@ -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;
}
});