From d06fe084e42d09b6afda7c36841bc76ed76aec31 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Thu, 13 Aug 2026 17:51:30 -0300 Subject: [PATCH] fix(sse): surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../services/qwenTokenPlanQuotaFetcher.ts | 373 ++++++++++++++++++ open-sse/services/usage.ts | 4 + open-sse/services/usage/qwen-token-plan.ts | 86 ++++ src/lib/usage/providerLimits.ts | 3 + src/shared/constants/providers.ts | 4 + src/sse/handlers/chat.ts | 6 + .../qwen-token-plan-quota-fetcher.test.ts | 272 +++++++++++++ 7 files changed, 748 insertions(+) create mode 100644 open-sse/services/qwenTokenPlanQuotaFetcher.ts create mode 100644 open-sse/services/usage/qwen-token-plan.ts create mode 100644 tests/unit/qwen-token-plan-quota-fetcher.test.ts diff --git a/open-sse/services/qwenTokenPlanQuotaFetcher.ts b/open-sse/services/qwenTokenPlanQuotaFetcher.ts new file mode 100644 index 0000000000..c24f5d6867 --- /dev/null +++ b/open-sse/services/qwenTokenPlanQuotaFetcher.ts @@ -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 + * form body: product, action, sec_token, region, params = + * {"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/","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 perPercentage (fraction used, 0..1) + + * perResetTime (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: perPercentage / perResetTime) +const WINDOW_FIELD_MAP: Record = { + "5Hour": QWEN_TOKEN_PLAN_WINDOW_5H, + "1Week": QWEN_TOKEN_PLAN_WINDOW_WEEKLY, +}; + +export interface QwenTokenPlanQuota extends QuotaInfo { + windows: Record; + /** 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(); +const tierCache = new Map(); +const secTokenCache = new Map(); + +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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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 | 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 | 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 { + 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 { + 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. */ +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 { + const obj = toRecord(payload); + const windows: Record = {}; + + 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 { + 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 +): Promise { + 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) + : 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, + ]); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index e6979d172b..3634f7e3b5 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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; @@ -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": diff --git a/open-sse/services/usage/qwen-token-plan.ts b/open-sse/services/usage/qwen-token-plan.ts new file mode 100644 index 0000000000..ed58fe26b7 --- /dev/null +++ b/open-sse/services/usage/qwen-token-plan.ts @@ -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 +) { + 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 = {}; + + 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}` }; + } +} diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 16f884f9de..3d89183e28 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -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"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 48841ddcae..143a11a45d 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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) ── diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 446dbb61ea..17d9c52548 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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. diff --git a/tests/unit/qwen-token-plan-quota-fetcher.test.ts b/tests/unit/qwen-token-plan-quota-fetcher.test.ts new file mode 100644 index 0000000000..718b4b1c51 --- /dev/null +++ b/tests/unit/qwen-token-plan-quota-fetcher.test.ts @@ -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 ?? "no token here", { + 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 } + ).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; + 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 } + ).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: + '', + }); + + 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("nothing"), 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")); +});