Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +443 to +449

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime exceptions (e.g., TypeError: Cannot read properties of null), it is safer to filter out any non-object or null elements from innerData before mapping over them.

      return (Array.isArray(innerData) ? innerData : [])
        .filter((item: any) => item && typeof item === "object")
        .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",
Expand Down
125 changes: 125 additions & 0 deletions tests/unit/qwen-web-models-discovery-3931.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
Loading