mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
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.
This commit is contained in:
1
changelog.d/fixes/13299-token-plan-live-catalog.md
Normal file
1
changelog.d/fixes/13299-token-plan-live-catalog.md
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
89
src/lib/providerModels/tokenPlanModelDiscovery.ts
Normal file
89
src/lib/providerModels/tokenPlanModelDiscovery.ts
Normal file
@@ -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 }));
|
||||
}
|
||||
76
tests/unit/token-plan-model-discovery.test.ts
Normal file
76
tests/unit/token-plan-model-discovery.test.ts
Normal file
@@ -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"]),
|
||||
"<html>login</html>",
|
||||
]) {
|
||||
assert.throws(() => parseTokenPlanCatalog(payload));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user