mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
fix(sse): surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight
The personal Token Plan (5-hour / 7-day sliding windows) has no official OpenAPI and the inference API key cannot read it. Add a cookie-authenticated fetcher for the console gateway shared by home.qwencloud.com and the Model Studio console (contract captured live from a logged-in session): - open-sse/services/qwenTokenPlanQuotaFetcher.ts: POST /data/api.json (IntlBroadScopeAspnGateway / sfm_bailian) for usage + quota-config + subscription; sec_token resolved best-effort from the dashboard HTML; per-window parse (fields are omitted while a window is Temporarily Removed); 60s usage cache, 1h tier cache. - usage/qwen-token-plan.ts leaf + registration in the usage dispatcher, USAGE_FETCHER_PROVIDERS, USAGE_SUPPORTED_PROVIDERS, PROVIDER_LIMITS_APIKEY_PROVIDERS and bespoke preflight/monitor windows. - Also adds bailian-coding-plan to USAGE_SUPPORTED_PROVIDERS / PROVIDER_LIMITS_APIKEY_PROVIDERS: the coding-plan fetcher existed but the dashboard filtered those connections out (UI gap). Refs #9603 (Problema 1 — quota missing; the 429 recovery half is a follow-up).
This commit is contained in:
373
open-sse/services/qwenTokenPlanQuotaFetcher.ts
Normal file
373
open-sse/services/qwenTokenPlanQuotaFetcher.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* qwenTokenPlanQuotaFetcher.ts — Qwen Cloud / Alibaba Model Studio PERSONAL Token Plan
|
||||
* quota fetcher (issue #9603, "quota is missing").
|
||||
*
|
||||
* The personal Token Plan (5-hour / 7-day sliding windows) has NO official OpenAPI —
|
||||
* the console gateway is the only quota surface, and the inference API key does NOT
|
||||
* authenticate it. Both portals read the same backend:
|
||||
* - home.qwencloud.com portal → https://cs-data.qwencloud.com (default)
|
||||
* - Model Studio console (intl) → https://bailian-singapore-cs.alibabacloud.com
|
||||
*
|
||||
* Transport (captured live 2026-08-13 from a logged-in session):
|
||||
* POST {host}/data/api.json?product=sfm_bailian&action=IntlBroadScopeAspnGateway
|
||||
* &api=zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2F<endpoint>
|
||||
* form body: product, action, sec_token, region, params =
|
||||
* {"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/<endpoint>","V":"1.0",
|
||||
* "Data":{"commodityCode":"sfm_tokenplansolo_public_intl","cornerstoneParam":{...}}}
|
||||
* Auth: browser session Cookie (providerSpecificData or QWEN_CLOUD_COOKIE env).
|
||||
* sec_token: best-effort — resolved from the dashboard HTML (`SEC_TOKEN: "…"`) when
|
||||
* not provided; some accounts reject requests without it
|
||||
* (BailianGateway.Workspace.NotAuthorised).
|
||||
*
|
||||
* Windows: usage returns per<Window>Percentage (fraction used, 0..1) +
|
||||
* per<Window>ResetTime (epoch ms). Fields are OMITTED while a window is
|
||||
* "Temporarily Removed" (observed for 5-hour), so every window is optional.
|
||||
*
|
||||
* Cache: usage 60s per connection; subscription/quota-config (slow-moving tier data)
|
||||
* 1h per connection. Registration: registerQwenTokenPlanQuotaFetcher() at startup.
|
||||
*/
|
||||
|
||||
import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts";
|
||||
import { registerMonitorFetcher } from "./quotaMonitor.ts";
|
||||
import { throttleQuotaFetch } from "./quotaFetchThrottle.ts";
|
||||
|
||||
const DEFAULT_GATEWAY_HOST = "https://cs-data.qwencloud.com";
|
||||
const DEFAULT_DASHBOARD_URL = "https://home.qwencloud.com/";
|
||||
const GATEWAY_REGION = "ap-southeast-1";
|
||||
const GATEWAY_PRODUCT = "sfm_bailian";
|
||||
const GATEWAY_ACTION = "IntlBroadScopeAspnGateway";
|
||||
const COMMODITY_CODE = "sfm_tokenplansolo_public_intl";
|
||||
const TOKEN_PLAN_API_PREFIX = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/";
|
||||
|
||||
const USAGE_CACHE_TTL_MS = 60_000;
|
||||
const TIER_CACHE_TTL_MS = 60 * 60_000;
|
||||
|
||||
// Window keys surfaced to the dashboard / quota-window registry
|
||||
export const QWEN_TOKEN_PLAN_WINDOW_5H = "window_5h";
|
||||
export const QWEN_TOKEN_PLAN_WINDOW_WEEKLY = "window_weekly";
|
||||
|
||||
// usage payload field prefix → window key (fields: per<prefix>Percentage / per<prefix>ResetTime)
|
||||
const WINDOW_FIELD_MAP: Record<string, string> = {
|
||||
"5Hour": QWEN_TOKEN_PLAN_WINDOW_5H,
|
||||
"1Week": QWEN_TOKEN_PLAN_WINDOW_WEEKLY,
|
||||
};
|
||||
|
||||
export interface QwenTokenPlanQuota extends QuotaInfo {
|
||||
windows: Record<string, { percentUsed: number; resetAt: string | null }>;
|
||||
/** Subscription tier (e.g. "pro") or null when the subscription call failed. */
|
||||
specCode: string | null;
|
||||
/** Credit limits of the active tier (from quota-config), when resolvable. */
|
||||
tierLimits: { fiveHour: number | null; weekly: number | null };
|
||||
}
|
||||
|
||||
interface UsageCacheEntry {
|
||||
quota: QwenTokenPlanQuota;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface TierCacheEntry {
|
||||
specCode: string | null;
|
||||
tierLimits: { fiveHour: number | null; weekly: number | null };
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const usageCache = new Map<string, UsageCacheEntry>();
|
||||
const tierCache = new Map<string, TierCacheEntry>();
|
||||
const secTokenCache = new Map<string, { token: string; fetchedAt: number }>();
|
||||
|
||||
const _cacheCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of usageCache) {
|
||||
if (now - entry.fetchedAt > USAGE_CACHE_TTL_MS * 5) usageCache.delete(key);
|
||||
}
|
||||
for (const [key, entry] of tierCache) {
|
||||
if (now - entry.fetchedAt > TIER_CACHE_TTL_MS * 2) tierCache.delete(key);
|
||||
}
|
||||
for (const [key, entry] of secTokenCache) {
|
||||
if (now - entry.fetchedAt > TIER_CACHE_TTL_MS * 2) secTokenCache.delete(key);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) {
|
||||
(_cacheCleanup as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function getCookie(providerSpecificData: Record<string, unknown> | undefined): string {
|
||||
for (const key of ["qwenCloudCookie", "alibabaConsoleCookie", "cookie"]) {
|
||||
const value = toTrimmedString(providerSpecificData?.[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return process.env.QWEN_CLOUD_COOKIE?.trim() || "";
|
||||
}
|
||||
|
||||
function getConfiguredSecToken(providerSpecificData: Record<string, unknown> | undefined): string {
|
||||
for (const key of ["qwenCloudSecToken", "alibabaConsoleSecToken"]) {
|
||||
const value = toTrimmedString(providerSpecificData?.[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return process.env.QWEN_CLOUD_SEC_TOKEN?.trim() || "";
|
||||
}
|
||||
|
||||
function getGatewayHost(): string {
|
||||
const configured = process.env.QWEN_TOKEN_PLAN_HOST?.trim();
|
||||
if (!configured) return DEFAULT_GATEWAY_HOST;
|
||||
return /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
|
||||
}
|
||||
|
||||
function getDashboardUrl(): string {
|
||||
return process.env.QWEN_TOKEN_PLAN_DASHBOARD_URL?.trim() || DEFAULT_DASHBOARD_URL;
|
||||
}
|
||||
|
||||
/** Extract the console `SEC_TOKEN: "…"` embedded in the logged-in dashboard HTML. */
|
||||
export function extractQwenSecToken(html: string): string | null {
|
||||
const match = /SEC_?TOKEN["']?\s*[:=]\s*["']([^"']+)["']/i.exec(html);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function resolveSecToken(connectionId: string, cookie: string): Promise<string> {
|
||||
const cached = secTokenCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < TIER_CACHE_TTL_MS) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getDashboardUrl(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
|
||||
Accept: "text/html",
|
||||
},
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
const html = await response.text();
|
||||
const token = extractQwenSecToken(html);
|
||||
if (token) {
|
||||
secTokenCache.set(connectionId, { token, fetchedAt: Date.now() });
|
||||
return token;
|
||||
}
|
||||
} catch {
|
||||
// best-effort — some accounts work without sec_token
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── Gateway transport ───────────────────────────────────────────────────────
|
||||
|
||||
async function callGateway(
|
||||
endpoint: string,
|
||||
cookie: string,
|
||||
secToken: string
|
||||
): Promise<unknown | null> {
|
||||
const api = `${TOKEN_PLAN_API_PREFIX}${endpoint}`;
|
||||
const url = `${getGatewayHost()}/data/api.json?product=${GATEWAY_PRODUCT}&action=${GATEWAY_ACTION}&api=${encodeURIComponent(api)}`;
|
||||
|
||||
const params = JSON.stringify({
|
||||
Api: api,
|
||||
V: "1.0",
|
||||
Data: {
|
||||
commodityCode: COMMODITY_CODE,
|
||||
cornerstoneParam: {
|
||||
console: "ONE_CONSOLE",
|
||||
consoleSite: "QWENCLOUD",
|
||||
domain: "home.qwencloud.com",
|
||||
productCode: "p_efm",
|
||||
protocol: "V2",
|
||||
xsp_lang: "en-US",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body = new URLSearchParams({
|
||||
product: GATEWAY_PRODUCT,
|
||||
action: GATEWAY_ACTION,
|
||||
sec_token: secToken,
|
||||
region: GATEWAY_REGION,
|
||||
params,
|
||||
});
|
||||
|
||||
try {
|
||||
// #6911: space concurrent upstream quota fetches (mirrors bailianQuotaFetcher.ts).
|
||||
await throttleQuotaFetch();
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Origin: "https://home.qwencloud.com",
|
||||
Referer: "https://home.qwencloud.com/",
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
const raw = await response.json();
|
||||
return parseGatewayEnvelope(raw);
|
||||
} catch {
|
||||
// Network error, timeout, non-JSON (login redirect page) — fail open
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unwrap {code:"200", data:{DataV2:{data:{code:"SUCCESS", data:<payload>}}}} → payload. */
|
||||
function parseGatewayEnvelope(raw: unknown): unknown | null {
|
||||
const obj = toRecord(raw);
|
||||
if (obj["code"] !== "200" && obj["code"] !== 200) return null;
|
||||
const inner = toRecord(toRecord(toRecord(obj["data"])["DataV2"])["data"]);
|
||||
if (inner["code"] !== "SUCCESS" || inner["success"] !== true) return null;
|
||||
return inner["data"] ?? null;
|
||||
}
|
||||
|
||||
// ─── Parsers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseUsageWindows(
|
||||
payload: unknown
|
||||
): Record<string, { percentUsed: number; resetAt: string | null }> {
|
||||
const obj = toRecord(payload);
|
||||
const windows: Record<string, { percentUsed: number; resetAt: string | null }> = {};
|
||||
|
||||
for (const [fieldPrefix, windowKey] of Object.entries(WINDOW_FIELD_MAP)) {
|
||||
const percent = toNumberOrNull(obj[`per${fieldPrefix}Percentage`]);
|
||||
if (percent === null) continue; // window omitted (e.g. 5-hour "Temporarily Removed")
|
||||
const resetMs = toNumberOrNull(obj[`per${fieldPrefix}ResetTime`]);
|
||||
windows[windowKey] = {
|
||||
percentUsed: percent,
|
||||
resetAt: resetMs && resetMs > 0 ? new Date(resetMs).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
async function resolveTierInfo(
|
||||
connectionId: string,
|
||||
cookie: string,
|
||||
secToken: string
|
||||
): Promise<TierCacheEntry> {
|
||||
const cached = tierCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < TIER_CACHE_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [quotaConfig, subscription] = await Promise.all([
|
||||
callGateway("quota-config", cookie, secToken),
|
||||
callGateway("subscription", cookie, secToken),
|
||||
]);
|
||||
|
||||
const specCode = toTrimmedString(toRecord(subscription)["specCode"]) || null;
|
||||
const tierRecord = specCode ? toRecord(toRecord(quotaConfig)[specCode]) : {};
|
||||
const entry: TierCacheEntry = {
|
||||
specCode,
|
||||
tierLimits: {
|
||||
fiveHour: toNumberOrNull(tierRecord["five_hour"]),
|
||||
weekly: toNumberOrNull(tierRecord["weekly"]),
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
|
||||
tierCache.set(connectionId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ─── Core fetcher ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the personal Token Plan quota for a qwen-cloud-token-plan connection.
|
||||
* Returns percentUsed = max across the windows present in the usage response,
|
||||
* or null when no cookie is configured / the console session expired.
|
||||
*/
|
||||
export async function fetchQwenTokenPlanQuota(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
): Promise<QuotaInfo | null> {
|
||||
const cached = usageCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
|
||||
return cached.quota;
|
||||
}
|
||||
|
||||
const providerSpecificData =
|
||||
connection?.providerSpecificData &&
|
||||
typeof connection.providerSpecificData === "object" &&
|
||||
!Array.isArray(connection.providerSpecificData)
|
||||
? (connection.providerSpecificData as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const cookie = getCookie(providerSpecificData);
|
||||
if (!cookie) return null;
|
||||
|
||||
const secToken =
|
||||
getConfiguredSecToken(providerSpecificData) || (await resolveSecToken(connectionId, cookie));
|
||||
|
||||
const usagePayload = await callGateway("usage", cookie, secToken);
|
||||
if (usagePayload === null) return null;
|
||||
|
||||
const windows = parseUsageWindows(usagePayload);
|
||||
const windowEntries = Object.values(windows);
|
||||
if (windowEntries.length === 0) return null;
|
||||
|
||||
const worst = windowEntries.reduce((max, w) => (w.percentUsed > max.percentUsed ? w : max));
|
||||
|
||||
const tier = await resolveTierInfo(connectionId, cookie, secToken);
|
||||
const total = tier.tierLimits.weekly ?? 100;
|
||||
|
||||
const quota: QwenTokenPlanQuota = {
|
||||
used: Math.round(worst.percentUsed * total),
|
||||
total,
|
||||
percentUsed: worst.percentUsed,
|
||||
resetAt: worst.resetAt,
|
||||
windows,
|
||||
specCode: tier.specCode,
|
||||
tierLimits: tier.tierLimits,
|
||||
limitReached: worst.percentUsed >= 1,
|
||||
};
|
||||
|
||||
usageCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
}
|
||||
|
||||
// ─── Invalidation ────────────────────────────────────────────────────────────
|
||||
|
||||
export function invalidateQwenTokenPlanQuotaCache(connectionId: string): void {
|
||||
usageCache.delete(connectionId);
|
||||
tierCache.delete(connectionId);
|
||||
secTokenCache.delete(connectionId);
|
||||
}
|
||||
|
||||
// ─── Registration ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register the Qwen Token Plan quota fetcher with the preflight and monitor systems.
|
||||
* Call once at server startup (src/sse/handlers/chat.ts), BEFORE registerGenericQuotaFetchers().
|
||||
*/
|
||||
export function registerQwenTokenPlanQuotaFetcher(): void {
|
||||
registerQuotaFetcher("qwen-cloud-token-plan", fetchQwenTokenPlanQuota);
|
||||
registerMonitorFetcher("qwen-cloud-token-plan", fetchQwenTokenPlanQuota);
|
||||
registerQuotaWindows("qwen-cloud-token-plan", [
|
||||
QWEN_TOKEN_PLAN_WINDOW_5H,
|
||||
QWEN_TOKEN_PLAN_WINDOW_WEEKLY,
|
||||
]);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
|
||||
import { getGrokCliUsage } from "./usage/grokCli.ts";
|
||||
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
|
||||
import { getCommandCodeUsage } from "./usage/command-code.ts";
|
||||
import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts";
|
||||
import { getConolUsage } from "./conolUsage.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -111,6 +112,7 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"bailian-coding-plan",
|
||||
"qwen-cloud-token-plan",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
"opencode",
|
||||
@@ -202,6 +204,8 @@ export async function getUsageForProvider(
|
||||
return await getCrofUsage(apiKey || "");
|
||||
case "bailian-coding-plan":
|
||||
return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData);
|
||||
case "qwen-cloud-token-plan":
|
||||
return await getQwenTokenPlanUsage(id || "", apiKey || "", providerSpecificData);
|
||||
case "nanogpt":
|
||||
return await getNanoGptUsage(apiKey || "");
|
||||
case "deepseek":
|
||||
|
||||
86
open-sse/services/usage/qwen-token-plan.ts
Normal file
86
open-sse/services/usage/qwen-token-plan.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* usage/qwen-token-plan.ts — Qwen Cloud / Alibaba Model Studio personal Token Plan
|
||||
* usage leaf (issue #9603).
|
||||
*
|
||||
* Delegates to qwenTokenPlanQuotaFetcher (cookie-authenticated console gateway) and
|
||||
* shapes the 5-hour / weekly sliding windows into the standard usage response. The
|
||||
* inference API key cannot read this quota — the connection needs a console session
|
||||
* cookie in providerSpecificData (qwenCloudCookie / alibabaConsoleCookie / cookie)
|
||||
* or the QWEN_CLOUD_COOKIE env var.
|
||||
*/
|
||||
|
||||
import {
|
||||
fetchQwenTokenPlanQuota,
|
||||
QWEN_TOKEN_PLAN_WINDOW_5H,
|
||||
QWEN_TOKEN_PLAN_WINDOW_WEEKLY,
|
||||
type QwenTokenPlanQuota,
|
||||
} from "../qwenTokenPlanQuotaFetcher.ts";
|
||||
import type { UsageQuota } from "./quota.ts";
|
||||
|
||||
function windowToQuota(
|
||||
window: { percentUsed: number; resetAt: string | null } | undefined,
|
||||
totalCredits: number | null,
|
||||
displayName: string
|
||||
): UsageQuota | null {
|
||||
if (!window) return null;
|
||||
const total = totalCredits ?? 100;
|
||||
const used = Math.round(window.percentUsed * total);
|
||||
const remaining = Math.max(0, total - used);
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage: Math.round((1 - window.percentUsed) * 1000) / 10,
|
||||
resetAt: window.resetAt,
|
||||
unlimited: false,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Cloud personal Token Plan usage (5-hour + weekly sliding windows).
|
||||
*/
|
||||
export async function getQwenTokenPlanUsage(
|
||||
connectionId: string,
|
||||
apiKey: string,
|
||||
providerSpecificData?: Record<string, unknown>
|
||||
) {
|
||||
try {
|
||||
const quota = await fetchQwenTokenPlanQuota(connectionId, { apiKey, providerSpecificData });
|
||||
|
||||
if (!quota) {
|
||||
return {
|
||||
message:
|
||||
"Qwen Token Plan connected. Quota requires a console session cookie " +
|
||||
"(qwenCloudCookie in the connection settings or QWEN_CLOUD_COOKIE env) — " +
|
||||
"the inference API key cannot read it. Refresh the cookie if it expired.",
|
||||
};
|
||||
}
|
||||
|
||||
const tokenPlanQuota = quota as QwenTokenPlanQuota;
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
const fiveHour = windowToQuota(
|
||||
tokenPlanQuota.windows[QWEN_TOKEN_PLAN_WINDOW_5H],
|
||||
tokenPlanQuota.tierLimits.fiveHour,
|
||||
"5-hour window"
|
||||
);
|
||||
if (fiveHour) quotas.five_hour = fiveHour;
|
||||
|
||||
const weekly = windowToQuota(
|
||||
tokenPlanQuota.windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY],
|
||||
tokenPlanQuota.tierLimits.weekly,
|
||||
"Weekly window"
|
||||
);
|
||||
if (weekly) quotas.weekly = weekly;
|
||||
|
||||
const specCode = tokenPlanQuota.specCode;
|
||||
const plan = specCode
|
||||
? `Qwen Token Plan (${specCode.charAt(0).toUpperCase()}${specCode.slice(1)})`
|
||||
: "Qwen Token Plan";
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `Qwen Token Plan error: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,9 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"command-code",
|
||||
"conol-web",
|
||||
"cnl",
|
||||
// Alibaba Coding Plan (console API key) + Qwen personal Token Plan (console cookie) — #9603
|
||||
"bailian-coding-plan",
|
||||
"qwen-cloud-token-plan",
|
||||
]);
|
||||
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
|
||||
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
|
||||
|
||||
@@ -500,6 +500,10 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"command-code",
|
||||
"conol-web",
|
||||
"cnl",
|
||||
// Alibaba Coding Plan triple-window quota (#9603 UI gap — fetcher existed, list entry missing)
|
||||
"bailian-coding-plan",
|
||||
// Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway)
|
||||
"qwen-cloud-token-plan",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -148,6 +148,7 @@ import {
|
||||
registerCodexQuotaFetcher,
|
||||
} from "@omniroute/open-sse/services/codexQuotaFetcher.ts";
|
||||
import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/services/bailianQuotaFetcher.ts";
|
||||
import { registerQwenTokenPlanQuotaFetcher } from "@omniroute/open-sse/services/qwenTokenPlanQuotaFetcher.ts";
|
||||
import { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts";
|
||||
import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts";
|
||||
import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts";
|
||||
@@ -171,6 +172,11 @@ registerCodexQuotaFetcher();
|
||||
// can proactively switch accounts before quota is exhausted.
|
||||
registerBailianCodingPlanQuotaFetcher();
|
||||
|
||||
// Register the Qwen Cloud / Model Studio personal Token Plan fetcher (#9603).
|
||||
// Cookie-authenticated console gateway — 5-hour + weekly sliding windows.
|
||||
// Runs before registerGenericQuotaFetchers so the bespoke fetcher wins.
|
||||
registerQwenTokenPlanQuotaFetcher();
|
||||
|
||||
// Register CrofAI usage fetcher (subscription requests + credits balance).
|
||||
// Surfaces usable_requests + credits in the monitor and only blocks (preflight
|
||||
// opt-in) when the active bucket reaches zero.
|
||||
|
||||
272
tests/unit/qwen-token-plan-quota-fetcher.test.ts
Normal file
272
tests/unit/qwen-token-plan-quota-fetcher.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* qwen-token-plan-quota-fetcher.test.ts — Qwen Cloud / Alibaba Model Studio personal
|
||||
* Token Plan quota fetcher (issue #9603, Problema 1: quota is missing).
|
||||
*
|
||||
* Fixtures captured live (2026-08-13) from home.qwencloud.com/billing/subscription/
|
||||
* token-plan-individual — console gateway POST cs-data.qwencloud.com/data/api.json
|
||||
* (action=IntlBroadScopeAspnGateway, product=sfm_bailian), cookie-authenticated.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
QWEN_TOKEN_PLAN_WINDOW_5H,
|
||||
QWEN_TOKEN_PLAN_WINDOW_WEEKLY,
|
||||
extractQwenSecToken,
|
||||
fetchQwenTokenPlanQuota,
|
||||
invalidateQwenTokenPlanQuotaCache,
|
||||
registerQwenTokenPlanQuotaFetcher,
|
||||
} from "../../open-sse/services/qwenTokenPlanQuotaFetcher.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const RESET_MS = 1786714740000; // 2026-08-14 10:39 (captured per1WeekResetTime)
|
||||
|
||||
type FetchCall = { url: string; init: RequestInit | undefined };
|
||||
|
||||
function gatewayBody(payload: unknown, api: string): string {
|
||||
return JSON.stringify({
|
||||
code: "200",
|
||||
data: {
|
||||
DataV2: {
|
||||
ret: ["SUCCESS::ok"],
|
||||
data: { msg: "Success.", code: "SUCCESS", data: payload, success: true },
|
||||
},
|
||||
success: true,
|
||||
httpStatus: 200,
|
||||
errorCode: "",
|
||||
api,
|
||||
errorMsg: "",
|
||||
},
|
||||
httpStatusCode: "200",
|
||||
successResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
const USAGE_PAYLOAD = { per1WeekResetTime: RESET_MS, per1WeekPercentage: 0.55 };
|
||||
const QUOTA_CONFIG_PAYLOAD = {
|
||||
standard: { five_hour: 3000.0, weekly: 10000.0 },
|
||||
addon_quota: { extrabundle: 20000.0 },
|
||||
lite: { five_hour: 700.0, weekly: 2500.0 },
|
||||
pro: { five_hour: 12000.0, weekly: 40000.0 },
|
||||
};
|
||||
const SUBSCRIPTION_PAYLOAD = {
|
||||
instanceCode: "sfm_tokenplansolo_public_intl-sg-test",
|
||||
specCode: "pro",
|
||||
remainingDays: 24,
|
||||
startTime: 1786109803000,
|
||||
endTime: 1788796800000,
|
||||
autoRenewFlag: false,
|
||||
status: "VALID",
|
||||
};
|
||||
|
||||
function mockGateway(
|
||||
calls: FetchCall[],
|
||||
overrides?: { usagePayload?: unknown; dashboardHtml?: string }
|
||||
): void {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
|
||||
if (!url.includes("/data/api.json")) {
|
||||
// Dashboard HTML fetch (sec_token resolution)
|
||||
return new Response(overrides?.dashboardHtml ?? "<html>no token here</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
|
||||
const jsonHeaders = { "content-type": "application/json" };
|
||||
if (url.includes("%2Fusage")) {
|
||||
const payload =
|
||||
overrides && "usagePayload" in overrides ? overrides.usagePayload : USAGE_PAYLOAD;
|
||||
return new Response(gatewayBody(payload, "usage"), { status: 200, headers: jsonHeaders });
|
||||
}
|
||||
if (url.includes("%2Fquota-config")) {
|
||||
return new Response(gatewayBody(QUOTA_CONFIG_PAYLOAD, "quota-config"), {
|
||||
status: 200,
|
||||
headers: jsonHeaders,
|
||||
});
|
||||
}
|
||||
if (url.includes("%2Fsubscription")) {
|
||||
return new Response(gatewayBody(SUBSCRIPTION_PAYLOAD, "subscription"), {
|
||||
status: 200,
|
||||
headers: jsonHeaders,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ code: "404" }), { status: 404, headers: jsonHeaders });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
delete process.env.QWEN_CLOUD_COOKIE;
|
||||
delete process.env.QWEN_CLOUD_SEC_TOKEN;
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("fetchQwenTokenPlanQuota returns null without any cookie configured", async () => {
|
||||
const calls: FetchCall[] = [];
|
||||
mockGateway(calls);
|
||||
|
||||
const quota = await fetchQwenTokenPlanQuota(`qwen-nocookie-${Date.now()}`, {});
|
||||
|
||||
assert.equal(quota, null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("fetchQwenTokenPlanQuota parses the captured weekly-only usage response", async () => {
|
||||
const connectionId = `qwen-weekly-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
mockGateway(calls);
|
||||
|
||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
||||
providerSpecificData: { qwenCloudCookie: "token=abc123; aux=1", qwenCloudSecToken: "sec-tok" },
|
||||
});
|
||||
|
||||
assert.ok(quota, "expected quota, got null");
|
||||
assert.equal(quota.percentUsed, 0.55);
|
||||
assert.equal(quota.resetAt, new Date(RESET_MS).toISOString());
|
||||
|
||||
const windows = (
|
||||
quota as { windows: Record<string, { percentUsed: number; resetAt: string | null }> }
|
||||
).windows;
|
||||
assert.ok(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY], "weekly window missing");
|
||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY].percentUsed, 0.55);
|
||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY].resetAt, new Date(RESET_MS).toISOString());
|
||||
// 5-hour window "Temporarily Removed" → API omits per5Hour* fields → no window
|
||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_5H], undefined);
|
||||
|
||||
// Tier totals resolved via subscription.specCode → quota-config.pro
|
||||
assert.equal(quota.total, 40000);
|
||||
assert.equal(quota.used, Math.round(0.55 * 40000));
|
||||
assert.equal((quota as { specCode: string | null }).specCode, "pro");
|
||||
|
||||
// Request contract (captured shape)
|
||||
const usageCall = calls.find((c) => c.url.includes("%2Fusage"));
|
||||
assert.ok(usageCall, "usage gateway call missing");
|
||||
assert.equal(usageCall.init?.method, "POST");
|
||||
const headers = usageCall.init?.headers as Record<string, string>;
|
||||
assert.ok(String(headers["Cookie"] ?? headers["cookie"]).includes("token=abc123"));
|
||||
const body = String(usageCall.init?.body);
|
||||
assert.ok(body.includes("product=sfm_bailian"), "body missing product");
|
||||
assert.ok(body.includes("action=IntlBroadScopeAspnGateway"), "body missing action");
|
||||
assert.ok(body.includes("region=ap-southeast-1"), "body missing region");
|
||||
assert.ok(body.includes("sec_token=sec-tok"), "body missing sec_token");
|
||||
const params = new URLSearchParams(body).get("params");
|
||||
assert.ok(params, "body missing params");
|
||||
const parsedParams = JSON.parse(params) as {
|
||||
Api: string;
|
||||
V: string;
|
||||
Data: { commodityCode: string };
|
||||
};
|
||||
assert.equal(parsedParams.V, "1.0");
|
||||
assert.ok(parsedParams.Api.includes("/tokenplan/personal/api/v2/usage"));
|
||||
assert.equal(parsedParams.Data.commodityCode, "sfm_tokenplansolo_public_intl");
|
||||
|
||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchQwenTokenPlanQuota includes the 5-hour window when the API returns it", async () => {
|
||||
const connectionId = `qwen-5h-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
mockGateway(calls, {
|
||||
usagePayload: {
|
||||
per1WeekResetTime: RESET_MS,
|
||||
per1WeekPercentage: 0.55,
|
||||
per5HourResetTime: RESET_MS - 3_600_000,
|
||||
per5HourPercentage: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
||||
providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" },
|
||||
});
|
||||
|
||||
assert.ok(quota, "expected quota, got null");
|
||||
const windows = (
|
||||
quota as { windows: Record<string, { percentUsed: number; resetAt: string | null }> }
|
||||
).windows;
|
||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_5H]?.percentUsed, 0.7);
|
||||
// worst window wins
|
||||
assert.equal(quota.percentUsed, 0.7);
|
||||
assert.equal(quota.resetAt, new Date(RESET_MS - 3_600_000).toISOString());
|
||||
|
||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchQwenTokenPlanQuota returns null when the console session expired", async () => {
|
||||
const connectionId = `qwen-expired-${Date.now()}`;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ code: "ConsoleNeedLogin" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof globalThis.fetch;
|
||||
|
||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
||||
providerSpecificData: { qwenCloudCookie: "token=stale", qwenCloudSecToken: "sec-tok" },
|
||||
});
|
||||
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchQwenTokenPlanQuota resolves sec_token from the dashboard when absent", async () => {
|
||||
const connectionId = `qwen-sectoken-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
mockGateway(calls, {
|
||||
dashboardHtml:
|
||||
'<script>window.X = { IS_CERTIFIED: "true", SEC_TOKEN: "resolved-tok" };</script>',
|
||||
});
|
||||
|
||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
||||
providerSpecificData: { qwenCloudCookie: "token=abc" },
|
||||
});
|
||||
|
||||
assert.ok(quota, "expected quota, got null");
|
||||
const dashboardCall = calls.find((c) => !c.url.includes("/data/api.json"));
|
||||
assert.ok(dashboardCall, "dashboard fetch for sec_token missing");
|
||||
const usageCall = calls.find((c) => c.url.includes("%2Fusage"));
|
||||
assert.ok(String(usageCall?.init?.body).includes("sec_token=resolved-tok"));
|
||||
|
||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchQwenTokenPlanQuota serves the second call from cache", async () => {
|
||||
const connectionId = `qwen-cache-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
mockGateway(calls);
|
||||
|
||||
const connection = {
|
||||
providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" },
|
||||
};
|
||||
const first = await fetchQwenTokenPlanQuota(connectionId, connection);
|
||||
assert.ok(first);
|
||||
const callCountAfterFirst = calls.length;
|
||||
|
||||
const second = await fetchQwenTokenPlanQuota(connectionId, connection);
|
||||
assert.ok(second);
|
||||
assert.equal(calls.length, callCountAfterFirst);
|
||||
|
||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("extractQwenSecToken pulls SEC_TOKEN out of dashboard HTML", () => {
|
||||
assert.equal(extractQwenSecToken('foo SEC_TOKEN: "abc-123", bar'), "abc-123");
|
||||
assert.equal(extractQwenSecToken("<html>nothing</html>"), null);
|
||||
});
|
||||
|
||||
test("registerQwenTokenPlanQuotaFetcher registers without throwing", () => {
|
||||
registerQwenTokenPlanQuotaFetcher();
|
||||
});
|
||||
|
||||
test("qwen-cloud-token-plan and bailian-coding-plan are wired into the usage/UI lists", async () => {
|
||||
const { USAGE_FETCHER_PROVIDERS } = await import("../../open-sse/services/usage.ts");
|
||||
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
|
||||
assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("qwen-cloud-token-plan"));
|
||||
assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("qwen-cloud-token-plan"));
|
||||
// #9603 UI gap: coding-plan connections were filtered out of /dashboard/quota
|
||||
assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("bailian-coding-plan"));
|
||||
});
|
||||
Reference in New Issue
Block a user