From bd8a12f3044144ae9fa8e05988bf74b70e62c98f Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Wed, 16 Sep 2026 21:50:42 +0200 Subject: [PATCH] fix(providers): fetch live Qwen and Alibaba Token Plan catalogs (#13299) Merged. Both Token Plan providers had no `modelsUrl` and no discovery config, so Sync Models never even tried a live request. Fetching the public Personal Plan catalog through `safeOutboundFetch` with fixed hosts, no inference keys and no cookies, validating the gateway envelope and reusing the DashScope text-model classifier keeps this narrow and safe; a failed or media-only result falls back without touching the previous catalog. Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904. Thank you. --- .../fixes/13299-token-plan-live-catalog.md | 1 + src/app/api/providers/[id]/models/route.ts | 39 ++++++++ .../providerModels/tokenPlanModelDiscovery.ts | 89 +++++++++++++++++++ tests/unit/token-plan-model-discovery.test.ts | 76 ++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 changelog.d/fixes/13299-token-plan-live-catalog.md create mode 100644 src/lib/providerModels/tokenPlanModelDiscovery.ts create mode 100644 tests/unit/token-plan-model-discovery.test.ts diff --git a/changelog.d/fixes/13299-token-plan-live-catalog.md b/changelog.d/fixes/13299-token-plan-live-catalog.md new file mode 100644 index 0000000000..eebf6e3101 --- /dev/null +++ b/changelog.d/fixes/13299-token-plan-live-catalog.md @@ -0,0 +1 @@ +- **fix(providers):** Fetch Qwen and Alibaba Token Plan model catalogs through their authenticated console gateways, with public-only URL validation and local-catalog fallback when discovery is unavailable. ([#13299](https://github.com/diegosouzapw/OmniRoute/pull/13299)) — thanks @JxnLexn diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index ea43296990..f9f263c4a8 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -27,6 +27,11 @@ import { import { errorResponse, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig"; +import { + buildTokenPlanCatalogRequest, + isTokenPlanCatalogProvider, + parseTokenPlanCatalog, +} from "@/lib/providerModels/tokenPlanModelDiscovery"; import { resolveZedModels } from "@omniroute/open-sse/shared/zedAuth.ts"; import { fetchGitHubCopilotModels, @@ -1938,6 +1943,40 @@ export async function GET( } const xaiOauthLiveConfig = provider === "xai-oauth" ? getXaiOauthLiveModelsConfig() : undefined; + if (isTokenPlanCatalogProvider(provider)) { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + const disabledResponse = maybeReturnAutoFetchDisabled(); + if (disabledResponse) return disabledResponse; + + const catalogRequest = buildTokenPlanCatalogRequest( + provider, + connection.providerSpecificData + ); + let liveModels: Array<{ id: string; name: string }>; + try { + const response = await safeOutboundFetch(catalogRequest.url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + ...catalogRequest.init, + guard: "public-only", + proxyConfig: proxy, + }); + if (!response.ok) throw new Error("Token Plan catalog request failed"); + liveModels = parseTokenPlanCatalog(await response.json()); + } catch { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "Token Plan live catalog unavailable — using cached catalog", + localWarning: "Token Plan live catalog unavailable — using local catalog", + }); + if (fallback) return fallback; + return errorResponse(502, "Token Plan live catalog unavailable. Please try again later."); + } + return buildApiDiscoveryResponse(liveModels, undefined, { + catalogMode: "live_token_plan_catalog", + catalogScope: "product", + }); + } + const config = xaiOauthLiveConfig ?? (provider in PROVIDER_MODELS_CONFIG diff --git a/src/lib/providerModels/tokenPlanModelDiscovery.ts b/src/lib/providerModels/tokenPlanModelDiscovery.ts new file mode 100644 index 0000000000..047c2bac32 --- /dev/null +++ b/src/lib/providerModels/tokenPlanModelDiscovery.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { isDashscopeTextModelId } from "../../../open-sse/services/dashscopeTextModels.ts"; +import { resolveAlibabaProviderRegion } from "../../shared/constants/alibabaProviderRegions.ts"; + +// Public model list used by the official Qwen Personal Token Plan pricing page. +// This is a product catalog, not an account entitlement or quota check. +const MODEL_LIST_API = "zeldaEasy.bmp.bmpTokenPlanServcie.modelIdList"; + +export function isTokenPlanCatalogProvider(provider: string): boolean { + return provider === "qwen-cloud-token-plan" || provider === "bailian-coding-plan"; +} + +export function buildTokenPlanCatalogRequest(provider: string, providerSpecificData?: unknown) { + if (!isTokenPlanCatalogProvider(provider)) throw new Error("Unsupported Token Plan provider"); + const beijing = resolveAlibabaProviderRegion(provider, providerSpecificData) === "china-beijing"; + const alibaba = provider === "bailian-coding-plan"; + // The regional public Qwen catalog also covers the Alibaba Personal Token Plan. + const host = beijing + ? "cs-data.qianwenai.com" + : alibaba + ? "bailian-singapore-cs.alibabacloud.com" + : "cs-data.qwencloud.com"; + const action = beijing ? "BroadScopeAspnGateway" : "IntlBroadScopeAspnGateway"; + const query = new URLSearchParams({ action, product: "sfm_bailian", api: MODEL_LIST_API }); + const body = new URLSearchParams({ + product: "sfm_bailian", + action, + sec_token: "", + region: beijing ? "cn-beijing" : "ap-southeast-1", + params: JSON.stringify({ + Api: MODEL_LIST_API, + Data: { + edition: "PERSONAL", + cornerstoneParam: { + consoleSite: beijing ? "QIANWENAI" : alibaba ? "ALIYUN" : "QWENCLOUD", + domain: beijing + ? "www.qianwenai.com" + : alibaba + ? "modelstudio.console.alibabacloud.com" + : "www.qwencloud.com", + productCode: "p_efm", + protocol: "V2", + xsp_lang: beijing ? "zh-CN" : "en-US", + }, + }, + }), + }); + return { + url: `https://${host}/data/api.json?${query}`, + init: { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: body.toString(), + } satisfies RequestInit, + }; +} + +const successCode = z.union([z.literal("200"), z.literal(200)]); +const catalogEnvelope = z.object({ + code: successCode, + data: z.object({ + success: z.literal(true), + DataV2: z.object({ + data: z.object({ + code: successCode, + success: z.literal(true), + data: z + .array( + z + .string() + .trim() + .min(1) + .max(256) + .regex(/^[a-zA-Z0-9._-]+$/) + ) + .max(2000), + }), + }), + }), +}); + +export function parseTokenPlanCatalog(payload: unknown): Array<{ id: string; name: string }> { + const parsed = catalogEnvelope.safeParse(payload); + if (!parsed.success) throw new Error("Token Plan catalog returned an invalid response"); + const ids = [...new Set(parsed.data.data.DataV2.data.data)].filter(isDashscopeTextModelId); + // An unexpected empty/media-only response must not erase a previously usable chat catalog. + if (ids.length === 0) throw new Error("Token Plan catalog returned no chat models"); + return ids.map((id) => ({ id, name: id })); +} diff --git a/tests/unit/token-plan-model-discovery.test.ts b/tests/unit/token-plan-model-discovery.test.ts new file mode 100644 index 0000000000..aac3c811e1 --- /dev/null +++ b/tests/unit/token-plan-model-discovery.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildTokenPlanCatalogRequest, + isTokenPlanCatalogProvider, + parseTokenPlanCatalog, +} from "../../src/lib/providerModels/tokenPlanModelDiscovery.ts"; + +function envelope(ids: unknown[]) { + return { + code: "200", + data: { success: true, DataV2: { data: { code: "200", success: true, data: ids } } }, + }; +} + +test("Token Plan discovery uses regional public gateways without forwarding credentials", () => { + for (const provider of ["qwen-cloud-token-plan", "bailian-coding-plan"]) { + for (const region of ["global-sg", "china-beijing"]) { + const request = buildTokenPlanCatalogRequest(provider, { + region, + apiKey: "secret-key", + cookie: "secret-cookie", + baseUrl: "http://127.0.0.1/private", + }); + const expectedHost = + region === "china-beijing" + ? "cs-data.qianwenai.com" + : provider === "bailian-coding-plan" + ? "bailian-singapore-cs.alibabacloud.com" + : "cs-data.qwencloud.com"; + assert.equal(new URL(request.url).hostname, expectedHost); + assert.equal(request.init.method, "POST"); + assert.equal(JSON.stringify(request).includes("secret"), false); + const data = JSON.parse(new URLSearchParams(request.init.body).get("params")!).Data; + assert.equal(data.edition, "PERSONAL"); + } + } + assert.equal(isTokenPlanCatalogProvider("qwen-cloud"), false); + assert.equal(isTokenPlanCatalogProvider("alibaba"), false); + assert.throws(() => buildTokenPlanCatalogRequest("alibaba")); +}); + +test("live IDs survive without a static allowlist while media and duplicates are excluded", () => { + const models = parseTokenPlanCatalog( + envelope([ + "qwen3.8-flash", + "deepseek-v4-pro-0813", + "glm-5.2", + "qwen-future-model", + "qwen3.8-flash", + "qwen-image-3.0-pro", + "wan2.7-image", + "happyhorse-1.1-i2v", + "qwen-audio-3.0-asr-flash", + "qwen-audio-3.0-realtime-plus", + "qwen-audio-3.0-tts-plus", + ]) + ); + assert.deepEqual( + models.map(({ id }) => id), + ["qwen3.8-flash", "deepseek-v4-pro-0813", "glm-5.2", "qwen-future-model"] + ); +}); + +test("HTTP-success gateway errors and unusable catalogs cannot replace the cached list", () => { + for (const payload of [ + { code: "200", data: { success: false, errorCode: "LoginRequired" } }, + envelope([]), + envelope(["qwen-image-3.0-pro"]), + envelope([null]), + envelope(["qwen-valid", "../bad"]), + "login", + ]) { + assert.throws(() => parseTokenPlanCatalog(payload)); + } +});