From 5cb3cf88432c32c5f07a85cfd08ff6a3ccc2c8a5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:32:49 -0300 Subject: [PATCH] fix(providers): Vercel AI Gateway import lists the live /v1/models catalog (#4249) (#4254) --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- src/app/api/providers/[id]/models/route.ts | 4 + .../vercel-gateway-models-fetch-4249.test.ts | 128 ++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/unit/vercel-gateway-models-fetch-4249.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5e0a99e7..e6c251c2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ _In development — bullets added per PR; finalized at release._ - **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(providers): ZenMux model discovery now lists the live catalog (incl. the free models) instead of the stale 9-entry hardcoded list** — adding a ZenMux key validated fine, but the connection then showed `API unavailable — using local catalog` and was missing the free models ZenMux advertises (`z-ai/glm-5.2-free`, `moonshotai/kimi-k2.7-code-free`). Root cause: `zenmux` carries a correct `modelsUrl` in the registry, but — like `llm7`/`byteplus` before #3976 — it was not classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route never probed the upstream `/models` and fell through to the registry's hardcoded `models[]`. Added `zenmux` to `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `https://zenmux.ai/api/v1/models` (the `/chat/completions`-stripped `/models` candidate) and serves the live list, falling back to the local catalog only when the upstream fetch fails — import never breaks. ([#4202](https://github.com/diegosouzapw/OmniRoute/issues/4202) — thanks @mikmaneggahommie) +- **fix(providers): Vercel AI Gateway "import models" now loads the live catalog instead of nothing** — adding a Vercel AI Gateway key worked, but clicking **import** on the models page loaded nothing usable (manually adding the same models worked). Same class as #4202 (zenmux) / #3976 (llm7/byteplus): `vercel-ai-gateway` carries a real `baseUrl` (`https://ai-gateway.vercel.sh/v1/chat/completions`, format `openai`) in the registry, but was not classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route never probed the upstream `/models` and fell through to the registry's tiny 5-entry hardcoded `models[]`. Added `vercel-ai-gateway` to `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `https://ai-gateway.vercel.sh/v1/models` (the `/chat/completions`-stripped `/models` candidate) and serves the live list, falling back to the local catalog only when the upstream fetch fails — import never breaks. ([#4249](https://github.com/diegosouzapw/OmniRoute/issues/4249) — thanks @FerLuisxd) - **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 35587e2679..b3dccba554 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_19_4249_vercel_gateway_live_models": "Issue #4249 own growth: providers/[id]/models/route.ts 2534->2538 (+4 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `vercel-ai-gateway` + a 3-line comment). Same fix shape as #4202 (zenmux) / #3976 (llm7/byteplus): vercel-ai-gateway carries a real baseUrl (https://ai-gateway.vercel.sh/v1/chat/completions, format openai) but was unclassified by every live-fetch branch, so import served the 5-entry hardcoded registry catalog instead of the upstream list — the import button looked broken (loaded nothing usable) while manual add worked. The `/models` probe (after stripping /chat/completions) resolves to https://ai-gateway.vercel.sh/v1/models; falls back to the local catalog on upstream error so import never breaks. Pure additive Set membership; not extractable.", "_rebaseline_2026_06_19_4227_cursor_cloud_ui": "PR #4250 (#4227 Cursor Cloud Agent) cross-layer UI wiring own growth: cloud-agents/page.tsx 913->922 (+9 = one CLOUD_AGENTS dropdown entry for the new `cursor-cloud` provider, mirroring the existing jules/devin/codex-cloud entries). The backend agent (cursor.ts) + registry/types/credentials-route landed in the PR, but the dashboard CLOUD_AGENTS list, health PROVIDER_NAMES, and lobeProviderIcons maps are hardcoded (client/server boundary — the registry can't be imported into the client page), so the agent was API-usable but not selectable in the UI and rendered with the Jules fallback name/icon. The +9 is the single presentational data entry at the existing hardcoded list; not extractable (it IS the list). The health-name + icon adds land in non-frozen files.", "_rebaseline_2026_06_19_cost_telemetry_combo_prettier": "NOT this PR's logic change: combo.ts 2601->2605 (+4) is a pure Prettier reflow applied by the cost-telemetry-parity merge commit's lint-staged hook. The DEFAULT_WEIGHTS/ProviderCandidate/ScoringWeights import from ./autoCombo/scoring.ts is 102 chars (> printWidth 100) on release (it landed via a GitHub merge that never ran the local Prettier hook), so the hook wrapped it to the canonical 4-line form. Zero logic change (git diff -w empty); cost-telemetry-parity never edits combo.ts. Reverting is futile — Prettier re-wraps the 102-char import on any later commit. Bumped here so the merge surfacing the reflow carries its own baseline.", "_rebaseline_2026_06_18_cost_telemetry_parity": "Cost Telemetry Parity own growth: chatCore.ts 5095->5097 (+2 net at the existing non-streaming success return chokepoint, ~line 4629). The inline `headers: { ...buildOmniRouteResponseMetaHeaders({…}) }` spread was converted into an explicit `responseHeaders` Record + a single attachOmniRouteMetaHeaders(responseHeaders, {…, requestId: skillRequestId}) call, routing this return through the new choke-point helper and threading the new identity header X-OmniRoute-Request-Id (skillRequestId). The now-orphaned buildOmniRouteResponseMetaHeaders import was dropped (only attachOmniRouteMetaHeaders is used here), netting the construction to +2. The reusable helper attachOmniRouteMetaHeaders + the always-on X-OmniRoute-Version header live in the (non-frozen) src/domain/omnirouteResponseMeta.ts. Cohesive telemetry growth at the response chokepoint; the +2 is the headers-bag construction needed to mutate-in-place rather than spread, not extractable without hiding the return boundary. Structural shrink of chatCore.ts tracked in #3501.", @@ -134,7 +135,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": 2534, + "src/app/api/providers/[id]/models/route.ts": 2538, "src/app/api/providers/[id]/test/route.ts": 842, "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1465, diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 0c198c7869..7639e9d50b 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -151,6 +151,10 @@ const NAMED_OPENAI_STYLE_PROVIDERS = new Set([ // #4202: zenmux is the same case — its free models (e.g. z-ai/glm-5.2-free, // moonshotai/kimi-k2.7-code-free) live only on the upstream /models list. "zenmux", + // #4249: vercel-ai-gateway carries a real baseUrl (.../v1/chat/completions) but + // was unclassified, so import served the 5-entry hardcoded catalog instead of the + // live `https://ai-gateway.vercel.sh/v1/models` list. Falls back to local on error. + "vercel-ai-gateway", ]); function isNamedOpenAIStyleProvider(provider: string): boolean { diff --git a/tests/unit/vercel-gateway-models-fetch-4249.test.ts b/tests/unit/vercel-gateway-models-fetch-4249.test.ts new file mode 100644 index 0000000000..54fc16a03a --- /dev/null +++ b/tests/unit/vercel-gateway-models-fetch-4249.test.ts @@ -0,0 +1,128 @@ +/** + * TDD regression for #4249: the Vercel AI Gateway "import models" button on + * /models returned nothing usable — clicking import did not load the live + * catalog, even though manually adding the same models works. (@FerLuisxd) + * + * Root cause: `vercel-ai-gateway` carries a real `baseUrl` + * (`https://ai-gateway.vercel.sh/v1/chat/completions`, format "openai") in the + * registry, but is not classified by any live-fetch branch of the import route — + * it is not `openai-compatible-*`, not self-hosted, and not in + * NAMED_OPENAI_STYLE_PROVIDERS. So the route never probes the upstream `/models` + * and falls through to the registry's tiny hardcoded `models[]` (5 entries). + * + * Fix: add `vercel-ai-gateway` to NAMED_OPENAI_STYLE_PROVIDERS so the route does a + * live `/models` fetch. After stripping `/chat/completions`, the + * `${base}/models` candidate resolves to `https://ai-gateway.vercel.sh/v1/models` + * (the real Vercel AI Gateway models endpoint), normalized via + * `normalizeOpenAiLikeModelsResponse`, falling back to the local catalog only when + * the upstream fetch fails — import never breaks. Mirrors #4202 (zenmux) and + * #3976 (llm7/byteplus). + */ +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-4249-")); +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 }>; + source?: string; +} + +test("#4249 Vercel AI Gateway import fetches the live /v1/models catalog", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "vercel-ai-gateway", + authType: "apikey", + name: "vag-live", + apiKey: "vck_key", + }); + + let fetched = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + // `${base}/models` after stripping `/chat/completions` → the gateway endpoint. + if (String(url) === "https://ai-gateway.vercel.sh/v1/models") { + fetched = true; + return Response.json({ + object: "list", + data: [ + { id: "xai/grok-4" }, + { id: "openai/gpt-5.1" }, + { id: "anthropic/claude-opus-4.5" }, + ], + }); + } + // Bogus probe variants (…/v1/v1/models, …/chat/completions/models) → 404 + return new Response("not found", { status: 404 }); + }; + + 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, "vercel-ai-gateway"); + assert.equal(body.source, "api", "should serve the live upstream catalog, not local_catalog"); + assert.ok(fetched, "should have probed https://ai-gateway.vercel.sh/v1/models"); + const ids = body.models.map((m) => m.id); + assert.ok(ids.includes("xai/grok-4"), `live models missing from catalog: ${ids.join(",")}`); + assert.ok(ids.includes("openai/gpt-5.1"), `live models missing: ${ids.join(",")}`); + // The stale hardcoded registry entry must not be what we serve. + assert.ok( + !ids.includes("vercel/v0-1.5-md"), + "served the stale hardcoded registry catalog instead of the live list" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#4249 Vercel AI Gateway import falls back to the local catalog when the live fetch fails", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "vercel-ai-gateway", + authType: "apikey", + name: "vag-fallback", + apiKey: "vck_key_2", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("bad gateway", { status: 502 }); + + 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, "vercel-ai-gateway"); + assert.equal(body.source, "local_catalog", "import must not break when upstream is down"); + assert.ok(body.models.length > 0, "fallback catalog should be non-empty"); + } finally { + globalThis.fetch = originalFetch; + } +});