mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
committed by
GitHub
parent
fa210de473
commit
f7880453e2
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
125
tests/unit/qwen-web-models-discovery-3931.test.ts
Normal file
125
tests/unit/qwen-web-models-discovery-3931.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user