Compare commits

..

2 Commits

Author SHA1 Message Date
adevwithpurpose
e7718b0423 fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs
The subscription fetch guard (fetchGuard.ts) unconditionally blocked all
loopback/private IP ranges as SSRF protection, but the same feature already
permits loopback for the routing half (coreEndpoint.ts's
ALLOWED_LOCAL_CORE_HOSTS) — so an operator could route traffic through a
loopback core but could not fetch a proxy list from a loopback HTTP server.

Make the fetch guard local-first by reusing the existing
areLocalProviderUrlsAllowed() policy (default ON) from
outboundUrlGuardPolicy.ts: loopback/private hosts are now allowed as fetch
targets by default, while cloud-metadata/link-local (169.254.0.0/16, incl.
169.254.169.254 IMDS) and the unspecified address stay blocked
unconditionally, mirroring the provider-validation guard's "block-metadata"
mode. Callers that want the old strict behavior can pass
{ allowLocal: false }.

Closes #10158.
2026-08-14 18:39:08 -03:00
Diego Rodrigues de Sa e Souza
abd4df63dc fix(sse): surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight (#10290)
* 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).

* docs(env): document Qwen Token Plan quota env vars + regen omni-settings skill

QWEN_CLOUD_COOKIE, QWEN_CLOUD_SEC_TOKEN, QWEN_TOKEN_PLAN_HOST and
QWEN_TOKEN_PLAN_DASHBOARD_URL added to .env.example and
docs/reference/ENVIRONMENT.md (check:env-doc-sync), with the generated
omni-settings skill refreshed (check:agent-skills-sync).

Refs #9603

* revert: keep hand-tuned omni-settings thinking-budget section

The agent-skills-sync drift predates this PR (hand improvement from #10169
not yet synced into the generator source) — it fails on every open PR and
belongs to a base-reds fix, not this branch. Regenerating here would erase
the intentional content.

* feat(dashboard): add the Qwen/Model Studio console cookie field to the connection modal

The Token Plan quota fetcher is cookie-authenticated (the inference API key
cannot read the console gateway), but no modal field existed to paste that
cookie — so the quota was unconfigurable from the dashboard and the fetcher
could only ever return its 'needs a cookie' message.

Adds the field for qwen-cloud-token-plan and bailian-coding-plan alongside the
existing ollama-cloud / alibaba console-cookie inputs (same password-input,
blank-keeps-stored semantics), pre-fills it when editing a connection, and
extends the providerSpecificData string/length validation to the two new keys.

Tests: tests/unit/qwen-token-plan-cookie-field.test.ts (RED before, GREEN
after) covers persistence + trimming, the blank-input no-overwrite rule and
schema acceptance/rejection.

Refs #9603

* docs(dashboard): correct the Qwen console cookie instructions

The placeholder claimed the cookie looks like 'token=...'; the qwencloud
portal actually issues 'login_qwencloud_ticket=...' alongside cna/cnaui/aui
(mirroring login_aliyunid_ticket on the Alibaba console), so the hint pointed
at the wrong value.

Replaces the guesswork with the verified retrieval steps in all three places
an operator can hit — the modal field hint, the fetcher's 'needs a cookie'
message and .env.example/ENVIRONMENT.md: log in to home.qwencloud.com >
Billing > Subscription, F12 > Network, reload, filter by api.json, click a
request to cs-data.qwencloud.com and copy the WHOLE Cookie request header.
Also documents that the value must go on one line (it contains '=' and ';')
and that it dies with the browser session.

Refs #9603

* fix(dashboard): tolerate partial form objects in the qwen cookie branch

Adding bailian-coding-plan to QWEN_TOKEN_PLAN_PROVIDERS routed callers that
previously matched NO branch in assignQuotaScrapingProviderData into the new
one, which assumed the two new fields are always present. Older callers build
a partial form object, so buildAddProviderSpecificData threw:

  TypeError: Cannot read properties of undefined (reading 'trim')
  (tests/unit/dashboard/agentrouter-connection-modal-fields.test.ts)

Reads the new fields with optional chaining and adds a regression test that
calls the helper with those keys deleted for both providers.

Refs #9603

* refactor(dashboard): move quota-scraping form logic into a UI-free module

tests/unit/qwen-token-plan-cookie-field.test.ts imported QuotaScrapingFields
directly, which pulls `@/shared/components` and, through that barrel,
untranspiled ESM (@lobehub/icons). The node:test runner cannot parse it and
the whole test file died in CI with:

  SyntaxError: Unexpected token 'export'
    at @lobehub/icons/es/Ai21/components/Mono.js

(It passed locally, so only the CI shard surfaced it.)

Extracts the pure pieces — QWEN_TOKEN_PLAN_PROVIDERS, QuotaScrapingFieldValues,
EMPTY_QUOTA_SCRAPING_FIELDS and assignQuotaScrapingProviderData — into
quotaScrapingFieldValues.ts. The component imports them and re-exports the
public names, so every existing importer keeps its current path. The unit test
now targets the UI-free module.

Refs #9603

* fix(providers): point bailian-coding-plan at the Token Plan endpoint and its console

Two independent defects kept this provider unusable with a valid Alibaba
Token Plan key (verified live 2026-08-14 with the owner's key and cookie):

1. Wrong inference host. The catalog entry is named "Alibaba Token Plan",
   links to token-plan-overview and its hint asks for a Token Plan key, but
   the registry pointed at coding-intl.dashscope.aliyuncs.com — the Coding
   Plan host, which rejects Token Plan keys with 401 invalid_api_key. The
   documented Anthropic base URL for Token Plan is
   token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic
   (https://www.alibabacloud.com/help/en/model-studio/more-tools). Against
   the new host the same key returns 200 for all six registry models and a
   real completion; auth stays on x-api-key.

2. Wrong console identity for quota. The personal Token Plan is sold through
   two consoles sharing one backend, and the gateway validates the session
   against the console declared in the request: an Alibaba console cookie
   (login_aliyunid_ticket) sent with the QwenCloud identity is refused with
   BailianGateway.Login.NotLogined. resolveConsoleSite() now picks host,
   cornerstoneParam.consoleSite/domain and Origin/Referer from the cookie's
   login ticket, falling back to the provider. With that switch the same
   cookie returns usage/subscription/quota-config.

Also routes bailian-coding-plan quota through the Token Plan fetcher (the
Coding Plan call returns "Bad Request" for these accounts), keeping the old
fetcher as the fallback for real Coding Plan keys, and labels the plan by
console ("Alibaba Token Plan (Pro)" vs "Qwen …").

Live validation: inference 200 (qwen3.7-plus answered "FUNCIONA"); quota
12,934/40,000 credits, 67.7% remaining, resets 2026-08-20.

Refs #9603

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 15:40:33 -03:00
24 changed files with 1427 additions and 83 deletions

View File

@@ -2040,6 +2040,22 @@ APP_LOG_TO_FILE=true
# ALIBABA_CODING_PLAN_HOST=
# ALIBABA_CODING_PLAN_QUOTA_URL=
# ── Qwen Cloud / Model Studio personal Token Plan quota ──
# Cookie-authenticated console-gateway fetcher (issue #9603). Used by:
# open-sse/services/qwenTokenPlanQuotaFetcher.ts. Prefer the per-connection
# Dashboard fields (qwenCloudCookie / qwenCloudSecToken) — these env vars are
# global fallbacks. Cookie/sec_token are SENSITIVE session credentials.
# Getting the cookie: log in to home.qwencloud.com > Billing > Subscription,
# press F12 > Network, reload, filter by api.json, click any request to
# cs-data.qwencloud.com and copy the WHOLE Cookie value from Request Headers
# (it contains login_qwencloud_ticket). Paste it on ONE line — the value may
# contain '=' and ';'. It expires with the browser session; re-paste it when
# the dashboard reports an expired session.
# QWEN_CLOUD_COOKIE=
# QWEN_CLOUD_SEC_TOKEN=
# QWEN_TOKEN_PLAN_HOST=
# QWEN_TOKEN_PLAN_DASHBOARD_URL=
# ── Alibaba Model Studio free-tier quota sync ──
# Console front-end path overrides for the free-tier quota fetcher. Used by:
# open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher

View File

@@ -26,6 +26,6 @@ jobs:
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"

View File

@@ -0,0 +1 @@
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)

View File

@@ -8435,7 +8435,12 @@ components:
type: string
url:
type: string
description: Redacted subscription URL.
description: >-
Redacted subscription URL. May be a local/loopback address
(e.g. `http://127.0.0.1:8080/list`) — local-first fetch targets
are allowed by default (`OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS`);
cloud-metadata / link-local endpoints (169.254.0.0/16) are always
blocked.
enabled:
type: boolean
mode:

View File

@@ -1142,6 +1142,10 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. |
| `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. |
| `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. |
| `QWEN_CLOUD_COOKIE` | _(unset)_ | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Console session cookie for the Qwen Cloud / Model Studio personal Token Plan quota gateway (the inference API key cannot read it). Copy the whole `Cookie` request header — it contains `login_qwencloud_ticket` — from any `api.json` call to `cs-data.qwencloud.com` on home.qwencloud.com Billing Subscription (F12 Network). Sensitive and session-scoped; prefer the per-connection `qwenCloudCookie` Dashboard field. |
| `QWEN_CLOUD_SEC_TOKEN` | _(unset)_ | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Manual `sec_token` override for the Token Plan console gateway. Sensitive; when unset the fetcher resolves it from the dashboard HTML using the cookie. |
| `QWEN_TOKEN_PLAN_HOST` | `https://cs-data.qwencloud.com` | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Gateway host override for the personal Token Plan quota fetcher (e.g. `bailian-singapore-cs.alibabacloud.com` for the Model Studio console). |
| `QWEN_TOKEN_PLAN_DASHBOARD_URL` | `https://home.qwencloud.com/` | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Dashboard URL used to resolve `sec_token` from the logged-in HTML. |
| `ALIBABA_FREE_TIER_VISION_FE_PATH` | `/costing-balance/free-quota-image-video` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier vision/media quota. |
| `ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH` | `/costing-balance/free-quota-multimodal` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier multimodal quota. |
| `ALIBABA_FREE_TIER_AUDIO_FE_PATH` | `/costing-balance/free-quota-audio` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier audio quota. |

View File

@@ -60,7 +60,12 @@ export const bailian_coding_planProvider: RegistryEntry = {
alias: "bcp",
format: "claude",
executor: "default",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
// Token Plan endpoint (the catalog entry is "Alibaba Token Plan"). The former
// coding-intl.dashscope.aliyuncs.com host only accepts Coding Plan keys and rejects
// Token Plan keys with 401 invalid_api_key. Verified live 2026-08-14: this host
// returns 200 for every model below with the same key.
// Docs: https://www.alibabacloud.com/help/en/model-studio/more-tools
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1",
chatPath: "/messages",
authType: "apikey",
authHeader: "x-api-key",

View File

@@ -0,0 +1,437 @@
/**
* 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/";
/**
* The same personal Token Plan is sold through two consoles that share one backend.
* The gateway validates the browser session against the console identity sent in the
* request, so an Alibaba cookie paired with the QwenCloud identity is rejected with
* `BailianGateway.Login.NotLogined` (verified live 2026-08-14).
*/
export interface TokenPlanConsoleSite {
consoleSite: "QWENCLOUD" | "ALIYUN";
domain: string;
gatewayHost: string;
dashboardUrl: string;
origin: string;
}
const CONSOLE_SITES: Record<"qwencloud" | "aliyun", TokenPlanConsoleSite> = {
qwencloud: {
consoleSite: "QWENCLOUD",
domain: "home.qwencloud.com",
gatewayHost: DEFAULT_GATEWAY_HOST,
dashboardUrl: DEFAULT_DASHBOARD_URL,
origin: "https://home.qwencloud.com",
},
aliyun: {
consoleSite: "ALIYUN",
domain: "modelstudio.console.alibabacloud.com",
gatewayHost: "https://bailian-singapore-cs.alibabacloud.com",
dashboardUrl: "https://modelstudio.console.alibabacloud.com/",
origin: "https://modelstudio.console.alibabacloud.com",
},
};
/** Providers served by the Alibaba (Model Studio) console rather than QwenCloud. */
const ALIYUN_CONSOLE_PROVIDERS = new Set(["bailian-coding-plan", "alibaba", "alibaba-cn"]);
/**
* Pick the console identity for a cookie: the login ticket names its console
* (`login_aliyunid_ticket` vs `login_qwencloud_ticket`). Unmarked cookies fall back to
* the provider, then to QwenCloud.
*/
export function resolveConsoleSite(
cookie: string,
provider: string | undefined
): TokenPlanConsoleSite {
if (/login_aliyunid_ticket=/.test(cookie)) return CONSOLE_SITES.aliyun;
if (/login_qwencloud_ticket=/.test(cookie)) return CONSOLE_SITES.qwencloud;
if (provider && ALIYUN_CONSOLE_PROVIDERS.has(provider)) return CONSOLE_SITES.aliyun;
return CONSOLE_SITES.qwencloud;
}
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 }>;
/** Which console served the quota — drives the plan label shown in the dashboard. */
consoleSite: TokenPlanConsoleSite["consoleSite"];
/** 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(site: TokenPlanConsoleSite): string {
const configured = process.env.QWEN_TOKEN_PLAN_HOST?.trim();
if (!configured) return site.gatewayHost;
return /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
}
function getDashboardUrl(site: TokenPlanConsoleSite): string {
return process.env.QWEN_TOKEN_PLAN_DASHBOARD_URL?.trim() || site.dashboardUrl;
}
/** 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,
site: TokenPlanConsoleSite
): 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(site), {
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,
site: TokenPlanConsoleSite
): Promise<unknown | null> {
const api = `${TOKEN_PLAN_API_PREFIX}${endpoint}`;
const url = `${getGatewayHost(site)}/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: site.consoleSite,
domain: site.domain,
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: site.origin,
Referer: `${site.origin}/`,
},
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,
site: TokenPlanConsoleSite
): 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, site),
callGateway("subscription", cookie, secToken, site),
]);
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 site = resolveConsoleSite(
cookie,
typeof connection?.provider === "string" ? connection.provider : undefined
);
const secToken =
getConfiguredSecToken(providerSpecificData) ||
(await resolveSecToken(connectionId, cookie, site));
const usagePayload = await callGateway("usage", cookie, secToken, site);
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, site);
const total = tier.tierLimits.weekly ?? 100;
const quota: QwenTokenPlanQuota = {
used: Math.round(worst.percentUsed * total),
total,
percentUsed: worst.percentUsed,
resetAt: worst.resetAt,
windows,
consoleSite: site.consoleSite,
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,
]);
}

View File

@@ -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":

View File

@@ -10,6 +10,7 @@
*/
import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts";
import { getQwenTokenPlanUsage } from "./qwen-token-plan.ts";
/**
* Bailian (Alibaba Token Plan) Usage
@@ -21,11 +22,25 @@ export async function getBailianCodingPlanUsage(
providerSpecificData?: Record<string, unknown>
) {
try {
// The catalog entry is "Alibaba Token Plan" and now points at the Token Plan
// endpoint, so prefer the Token Plan quota (console cookie) when one is
// configured. The Coding Plan path below stays as the fallback for accounts
// that really do hold a Coding Plan key (#9603).
const tokenPlanUsage = await getQwenTokenPlanUsage(
connectionId,
apiKey,
providerSpecificData,
"bailian-coding-plan"
);
if ("quotas" in tokenPlanUsage) return tokenPlanUsage;
const connection = { apiKey, providerSpecificData };
const quota = await fetchBailianQuota(connectionId, connection);
if (!quota) {
return { message: "Alibaba Token Plan connected. Unable to fetch quota." };
// Neither surface answered — surface the Token Plan guidance, which tells the
// operator how to supply the cookie the console gateway requires.
return tokenPlanUsage;
}
const bailianQuota = quota as BailianTripleWindowQuota;

View File

@@ -0,0 +1,96 @@
/**
* 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>,
provider = "qwen-cloud-token-plan"
) {
try {
const quota = await fetchQwenTokenPlanQuota(connectionId, {
apiKey,
providerSpecificData,
provider,
});
if (!quota) {
return {
message:
"Qwen Token Plan connected. Quota needs a console session cookie — the inference " +
"API key cannot read it. Get it at home.qwencloud.com Billing Subscription " +
"(logged in): F12 Network, reload, filter by api.json, click a request to " +
"cs-data.qwencloud.com and copy the whole Cookie value from Request Headers " +
"(it contains login_qwencloud_ticket). Paste it into the connection's " +
"'Qwen / Model Studio console cookie' field, or set QWEN_CLOUD_COOKIE. " +
"The cookie expires with the browser session — re-paste it when this message returns.",
};
}
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 brand = tokenPlanQuota.consoleSite === "ALIYUN" ? "Alibaba" : "Qwen";
const plan = specCode
? `${brand} Token Plan (${specCode.charAt(0).toUpperCase()}${specCode.slice(1)})`
: `${brand} Token Plan`;
return { plan, quotas };
} catch (error) {
return { message: `Qwen Token Plan error: ${(error as Error).message}` };
}
}

View File

@@ -340,6 +340,8 @@ export default function EditConnectionModal({
opencodeGoAuthCookie: "",
ollamaCloudUsageCookie: "",
alibabaConsoleCookie: stringField(connection.providerSpecificData?.alibabaConsoleCookie),
qwenCloudCookie: stringField(connection.providerSpecificData?.qwenCloudCookie),
qwenCloudSecToken: stringField(connection.providerSpecificData?.qwenCloudSecToken),
alibabaConsoleSecToken: stringField(
connection.providerSpecificData?.alibabaConsoleSecToken
),

View File

@@ -3,44 +3,16 @@
import { Input } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
export type QuotaScrapingFieldValues = {
opencodeGoWorkspaceId: string;
opencodeGoAuthCookie: string;
ollamaCloudUsageCookie: string;
alibabaConsoleCookie: string;
alibabaConsoleSecToken: string;
};
import {
assignQuotaScrapingProviderData,
EMPTY_QUOTA_SCRAPING_FIELDS,
QWEN_TOKEN_PLAN_PROVIDERS,
type QuotaScrapingFieldValues,
} from "./quotaScrapingFieldValues";
export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = {
opencodeGoWorkspaceId: "",
opencodeGoAuthCookie: "",
ollamaCloudUsageCookie: "",
alibabaConsoleCookie: "",
alibabaConsoleSecToken: "",
};
export function assignQuotaScrapingProviderData(
provider: string | undefined,
values: QuotaScrapingFieldValues,
target: Record<string, unknown>
) {
if (provider === "opencode-go") {
target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined;
if (values.opencodeGoAuthCookie.trim()) {
target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim();
}
} else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) {
target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim();
} else if (
(provider === "alibaba" || provider === "alibaba-cn") &&
values.alibabaConsoleCookie.trim()
) {
target.alibabaConsoleCookie = values.alibabaConsoleCookie.trim();
if (values.alibabaConsoleSecToken.trim()) {
target.alibabaConsoleSecToken = values.alibabaConsoleSecToken.trim();
}
}
}
// Re-exported so existing importers (modals, tests) keep their current paths.
export { assignQuotaScrapingProviderData, EMPTY_QUOTA_SCRAPING_FIELDS };
export type { QuotaScrapingFieldValues };
type QuotaScrapingFieldsProps = {
provider?: string;
@@ -170,5 +142,50 @@ export default function QuotaScrapingFields({
);
}
if (QWEN_TOKEN_PLAN_PROVIDERS.has(provider ?? "")) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-border/50 bg-surface/20 p-4">
<Input
label={providerText(t, "qwenCloudCookieLabel", "Qwen / Model Studio console cookie")}
name="qwenCloudCookie"
type="password"
value={values.qwenCloudCookie}
onChange={(e) => onChange({ qwenCloudCookie: e.target.value })}
placeholder="cna=...; login_qwencloud_ticket=...; ..."
hint={providerText(
t,
"qwenCloudCookieHint",
(editMode ? "Leave blank to keep the stored cookie. " : "") +
"Required for Token Plan quota — the inference API key cannot read it. " +
"How to get it: open home.qwencloud.com Billing Subscription while logged in, " +
"press F12 Network, reload the page, filter by api.json, click any request to " +
"cs-data.qwencloud.com, then under Request Headers copy the WHOLE Cookie value " +
"(it contains login_qwencloud_ticket). It expires with the browser session — " +
"re-paste it when the quota reports an expired session."
)}
autoComplete="off"
spellCheck={false}
autoCapitalize="off"
/>
<Input
label={providerText(t, "qwenCloudSecTokenLabel", "Qwen console sec_token (optional)")}
name="qwenCloudSecToken"
type="password"
value={values.qwenCloudSecToken}
onChange={(e) => onChange({ qwenCloudSecToken: e.target.value })}
placeholder="GjRV..."
hint={providerText(
t,
"qwenCloudSecTokenHint",
"Optional — resolved automatically from the dashboard. Set it only if quota sync reports a permission error."
)}
autoComplete="off"
spellCheck={false}
autoCapitalize="off"
/>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,64 @@
/**
* quotaScrapingFieldValues.ts — form-state shape + persistence rules for the
* quota-scraping credential fields (cookies / workspace ids) rendered by
* QuotaScrapingFields.tsx.
*
* Kept in a UI-free module on purpose: importing the .tsx pulls in
* `@/shared/components`, whose barrel reaches untranspiled ESM deps
* (@lobehub/icons) that the node:test runner cannot parse. Unit tests import
* this file instead; the component re-exports it for existing callers.
*/
/** Providers whose quota lives behind the Qwen/Model Studio console gateway (#9603). */
export const QWEN_TOKEN_PLAN_PROVIDERS = new Set(["qwen-cloud-token-plan", "bailian-coding-plan"]);
export type QuotaScrapingFieldValues = {
opencodeGoWorkspaceId: string;
opencodeGoAuthCookie: string;
ollamaCloudUsageCookie: string;
alibabaConsoleCookie: string;
alibabaConsoleSecToken: string;
qwenCloudCookie: string;
qwenCloudSecToken: string;
};
export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = {
opencodeGoWorkspaceId: "",
opencodeGoAuthCookie: "",
ollamaCloudUsageCookie: "",
alibabaConsoleCookie: "",
alibabaConsoleSecToken: "",
qwenCloudCookie: "",
qwenCloudSecToken: "",
};
export function assignQuotaScrapingProviderData(
provider: string | undefined,
values: QuotaScrapingFieldValues,
target: Record<string, unknown>
) {
if (provider === "opencode-go") {
target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined;
if (values.opencodeGoAuthCookie.trim()) {
target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim();
}
} else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) {
target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim();
} else if (
(provider === "alibaba" || provider === "alibaba-cn") &&
values.alibabaConsoleCookie.trim()
) {
target.alibabaConsoleCookie = values.alibabaConsoleCookie.trim();
if (values.alibabaConsoleSecToken.trim()) {
target.alibabaConsoleSecToken = values.alibabaConsoleSecToken.trim();
}
} else if (QWEN_TOKEN_PLAN_PROVIDERS.has(provider ?? "") && values.qwenCloudCookie?.trim()) {
// Optional access: callers (AddApiKeyModal/EditConnectionModal form state, and
// existing tests) may pass a partial form object without the newer fields —
// bailian-coding-plan previously matched no branch here at all.
target.qwenCloudCookie = values.qwenCloudCookie.trim();
if (values.qwenCloudSecToken?.trim()) {
target.qwenCloudSecToken = values.qwenCloudSecToken.trim();
}
}
}

View File

@@ -3,30 +3,57 @@
*
* The subscription URL is fetched server-side (see `subscriptionService
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
* subscription link — could point OmniRoute at internal services or cloud
* metadata (SSRF). Only http/https to non-internal hosts are allowed:
* loopback / private / link-local (incl. 169.254.0.0/16 cloud metadata) /
* unspecified addresses are blocked.
* subscription link — could point OmniRoute at cloud metadata (SSRF). Only
* http/https to non-metadata hosts are allowed.
*
* Local-first (#10158): OmniRoute already lets an operator route ALL traffic
* through a loopback core (`coreEndpoint.ts` allows `127.0.0.1`/`::1`/
* `localhost`), so a subscription fetch target on loopback/private ranges is
* ALLOWED by default (`allowLocal: true`, matching the local-first default of
* `areLocalProviderUrlsAllowed()` in `src/shared/network/outboundUrlGuardPolicy
* .ts`) — mirroring that policy's "block-metadata" mode. Cloud-metadata /
* link-local (`169.254.0.0/16`, incl. `169.254.169.254` IMDS) and the
* unspecified address (`0.0.0.0/8`) are blocked UNCONDITIONALLY regardless of
* `allowLocal`, since they have no legitimate subscription-source use case.
* Callers that want the old strict (public-only) behavior pass
* `{ allowLocal: false }`.
*
* Hostname resolution is re-checked at fetch time (also using the IP-range
* helpers here) so a hostname that resolves to an internal address is still
* refused. Splitting the logic into pure functions keeps it unit-testable
* without DNS / the full stack.
* without DNS / the full stack. No `@/`-aliased or DB-backed imports here —
* the `allowLocal` policy decision is made by the caller (subscriptionService,
* which is already DB-backed) and passed in as a plain boolean.
*/
/** Only these URL schemes may be used to *fetch* a subscription. */
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
// Blocked IPv4 ranges (base, mask) as 32-bit ints.
const BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
// Blocked UNCONDITIONALLY, regardless of `allowLocal` — the classic SSRF→cloud
// credential pivot; never a legitimate subscription source.
const ALWAYS_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
[0x00000000, 0xff000000], // 0.0.0.0/8 unspecified
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 link-local (incl. cloud metadata IMDS)
];
// Blocked only when `allowLocal` is false (strict/public-only mode).
const LOCAL_ONLY_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
[0x7f000000, 0xff000000], // 127.0.0.0/8 loopback
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
[0xc0a80000, 0xffff0000], // 192.168.0.0/16 private
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 link-local (cloud metadata)
];
export interface FetchGuardOptions {
/**
* When true (default), loopback/private hosts are allowed as fetch targets
* ("local-first" — matches `areLocalProviderUrlsAllowed()`'s default). Cloud
* metadata / link-local is blocked unconditionally either way. Pass `false`
* to restore the strict public-only behavior.
*/
allowLocal?: boolean;
}
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
export function isIpv4Literal(host: string): boolean {
@@ -44,20 +71,24 @@ export function ipv4ToLong(host: string): number | null {
return (parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]) >>> 0;
}
export function isIpv4Blocked(ip: string): boolean {
export function isIpv4Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
const allowLocal = opts.allowLocal ?? true;
const n = ipv4ToLong(ip);
if (n === null) return false;
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
// comparing so masked results with the high bit set aren't negative.
return BLOCKED_IPV4.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
const ranges = allowLocal ? ALWAYS_BLOCKED_IPV4 : [...ALWAYS_BLOCKED_IPV4, ...LOCAL_ONLY_BLOCKED_IPV4];
return ranges.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
}
/** Blocked IPv6 addresses: loopback, unspecified, link-local, ULA. */
export function isIpv6Blocked(ip: string): boolean {
/** Blocked IPv6 addresses: unspecified/link-local always; loopback/ULA only when strict. */
export function isIpv6Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
const allowLocal = opts.allowLocal ?? true;
const h = ip.toLowerCase();
if (h === "::") return true; // unspecified — always blocked
if (h.startsWith("fe80")) return true; // link-local — always blocked
if (allowLocal) return false;
if (h === "::1") return true; // loopback
if (h === "::") return true; // unspecified
if (h.startsWith("fe80")) return true; // link-local
if (h.startsWith("fc") || h.startsWith("fd")) return true; // unique local
return false;
}
@@ -78,20 +109,22 @@ export function isIpLiteral(host: string): boolean {
* the `dns` module convention (4 = IPv4, 6 = IPv6; missing ⇒ treat as v4).
*/
export function isAnyResolvedAddressBlocked(
addrs: ReadonlyArray<{ address: string; family?: number }>
addrs: ReadonlyArray<{ address: string; family?: number }>,
opts: FetchGuardOptions = {}
): boolean {
return addrs.some(({ address, family }) => {
const fam = family === 6 ? 6 : 4;
return fam === 6 ? isIpv6Blocked(address) : isIpv4Blocked(address);
return fam === 6 ? isIpv6Blocked(address, opts) : isIpv4Blocked(address, opts);
});
}
/**
* Structural check (no DNS). True only if the scheme is allowed AND, when the
* host is an IP literal, it is not in a blocked range. Hostnames pass the
* structural check — they are resolved and re-checked at fetch time.
* host is an IP literal, it is not in a blocked range for the given
* `allowLocal` mode. Hostnames pass the structural check — they are resolved
* and re-checked at fetch time.
*/
export function isSubscriptionFetchUrlAllowed(url: string): boolean {
export function isSubscriptionFetchUrlAllowed(url: string, opts: FetchGuardOptions = {}): boolean {
let u: URL;
try {
u = new URL(url);
@@ -104,8 +137,8 @@ export function isSubscriptionFetchUrlAllowed(url: string): boolean {
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
if (host === "") return false;
if (isIpLiteral(host)) {
if (isIpv4Literal(host)) return !isIpv4Blocked(host);
return !isIpv6Blocked(host);
if (isIpv4Literal(host)) return !isIpv4Blocked(host, opts);
return !isIpv6Blocked(host, opts);
}
return true; // hostname: resolved + checked at fetch time
}

View File

@@ -36,7 +36,9 @@ import {
isSubscriptionFetchUrlAllowed,
isIpLiteral,
isAnyResolvedAddressBlocked,
type FetchGuardOptions,
} from "./fetchGuard";
import { areLocalProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
import { withRetry } from "./fetchRetry";
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
@@ -280,13 +282,20 @@ export async function deleteSubscription(id: string): Promise<boolean> {
// ───────────────────────────── Sync + apply ─────────────────────────────
/**
* Refuse to fetch a subscription URL unless it is http/https to a non-internal
* Refuse to fetch a subscription URL unless it is http/https to an allowed
* host. IP literals are checked structurally; hostnames are resolved and the
* resolved addresses are re-checked (fail closed on resolution errors). This
* blocks SSRF to internal services / cloud metadata (169.254.169.254).
* resolved addresses are re-checked (fail closed on resolution errors).
*
* Local-first (#10158): loopback/private fetch targets are ALLOWED when
* `areLocalProviderUrlsAllowed()` is on (default ON — same local-first policy
* already used for provider validation, and consistent with
* `coreEndpoint.ts` already permitting a loopback routing core). Cloud
* metadata / link-local (169.254.0.0/16, incl. 169.254.169.254 IMDS) is
* blocked UNCONDITIONALLY regardless of that flag.
*/
async function assertSafeFetchTarget(url: string): Promise<void> {
if (!isSubscriptionFetchUrlAllowed(url)) {
const guardOpts: FetchGuardOptions = { allowLocal: areLocalProviderUrlsAllowed() };
if (!isSubscriptionFetchUrlAllowed(url, guardOpts)) {
throw new Error("Subscription URL is not allowed (scheme or host blocked)");
}
const host = new URL(url).hostname.toLowerCase();
@@ -299,7 +308,7 @@ async function assertSafeFetchTarget(url: string): Promise<void> {
try {
const dns = await import("node:dns");
const addrs = await dns.promises.lookup(bare, { all: true });
if (isAnyResolvedAddressBlocked(addrs)) {
if (isAnyResolvedAddressBlocked(addrs, guardOpts)) {
throw new Error("Subscription host resolves to a blocked (internal) address");
}
} catch (e) {

View File

@@ -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";

View File

@@ -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) ──

View File

@@ -328,6 +328,8 @@ export function validateProviderSpecificData(
"usageCookie",
"alibabaConsoleCookie",
"alibabaConsoleSecToken",
"qwenCloudCookie",
"qwenCloudSecToken",
] as const) {
const value = data[key];
if (value !== undefined && value !== null && typeof value !== "string") {

View File

@@ -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.

View File

@@ -23,34 +23,68 @@ test("non-http(s) schemes are rejected", () => {
assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false);
});
test("blocked IPv4 literals are rejected", () => {
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0"]) {
test("local-first (#10158): loopback/private IPv4 literals are ALLOWED by default", () => {
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), true, ip);
}
});
test("cloud-metadata / link-local / unspecified IPv4 literals are ALWAYS blocked", () => {
for (const ip of ["169.254.169.254", "169.254.1.1", "0.0.0.0"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), false, ip);
}
});
test("strict mode (allowLocal: false) rejects loopback/private IPv4 literals", () => {
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0"]) {
assert.equal(
isSubscriptionFetchUrlAllowed(`https://${ip}/x`, { allowLocal: false }),
false,
ip
);
}
});
test("public IPv4 literals are allowed", () => {
assert.equal(isSubscriptionFetchUrlAllowed("https://8.8.8.8/x"), true);
assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true);
});
test("blocked IPv6 literals are rejected (bracketed)", () => {
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
test("local-first (#10158): loopback/ULA IPv6 literals are ALLOWED by default", () => {
for (const ip of ["::1", "fc00::1", "fd12:3456::1"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), true, ip);
}
});
test("unspecified / link-local IPv6 literals are ALWAYS blocked", () => {
for (const ip of ["::", "fe80::1"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), false, ip);
}
});
test("strict mode (allowLocal: false) rejects loopback/ULA IPv6 literals", () => {
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
assert.equal(
isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`, { allowLocal: false }),
false,
ip
);
}
});
test("malformed / empty-host URLs are rejected", () => {
assert.equal(isSubscriptionFetchUrlAllowed("not a url"), false);
assert.equal(isSubscriptionFetchUrlAllowed(""), false);
assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host
});
test("ip-range + literal helpers", () => {
assert.equal(isIpv4Blocked("127.0.0.1"), true);
assert.equal(isIpv4Blocked("169.254.169.254"), true);
test("ip-range + literal helpers (local-first defaults)", () => {
assert.equal(isIpv4Blocked("127.0.0.1"), false); // allowed by default (local-first)
assert.equal(isIpv4Blocked("127.0.0.1", { allowLocal: false }), true);
assert.equal(isIpv4Blocked("169.254.169.254"), true); // always blocked
assert.equal(isIpv4Blocked("8.8.8.8"), false);
assert.equal(isIpv6Blocked("::1"), true);
assert.equal(isIpv6Blocked("::1"), false); // allowed by default (local-first)
assert.equal(isIpv6Blocked("::1", { allowLocal: false }), true);
assert.equal(isIpv6Blocked("2606:4700::1111"), false);
assert.equal(isIpLiteral("127.0.0.1"), true);
assert.equal(isIpLiteral("::1"), true);
@@ -58,14 +92,22 @@ test("ip-range + literal helpers", () => {
assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]);
});
test("multi-record DNS: blocks if ANY resolved address is internal", () => {
// Hostname resolves to a public AND a private address — must be refused
// (closes the first-address-only bypass).
test("multi-record DNS: blocks if ANY resolved address is metadata/link-local (local-first default)", () => {
// A private address alongside a public one is now ALLOWED by default
// (local-first) — only cloud-metadata/link-local addresses stay blocked.
assert.equal(
isAnyResolvedAddressBlocked([
{ address: "8.8.8.8", family: 4 },
{ address: "192.168.1.10", family: 4 },
]),
false
);
// A cloud-metadata address among public records → still blocked.
assert.equal(
isAnyResolvedAddressBlocked([
{ address: "8.8.8.8", family: 4 },
{ address: "169.254.169.254", family: 4 },
]),
true
);
// All public → allowed.
@@ -76,14 +118,45 @@ test("multi-record DNS: blocks if ANY resolved address is internal", () => {
]),
false
);
// A single internal IPv6 among public records → blocked.
// Strict mode (allowLocal: false): a private address is blocked again.
assert.equal(
isAnyResolvedAddressBlocked([
{ address: "2606:4700::1111", family: 6 },
{ address: "fd00::1", family: 6 },
]),
isAnyResolvedAddressBlocked(
[
{ address: "8.8.8.8", family: 4 },
{ address: "192.168.1.10", family: 4 },
],
{ allowLocal: false }
),
true
);
// A single internal IPv6 among public records → blocked in strict mode.
assert.equal(
isAnyResolvedAddressBlocked(
[
{ address: "2606:4700::1111", family: 6 },
{ address: "fd00::1", family: 6 },
],
{ allowLocal: false }
),
true
);
// Empty result set → nothing blocked.
assert.equal(isAnyResolvedAddressBlocked([]), false);
});
// ─────────────────── Regression: #10158 local proxy subscription ───────────────────
// Promoted from the TDD probe (was RED on release/v3.8.50: local http subscription
// URLs were rejected by the strict SSRF guard even though the same feature already
// permits loopback for the routing half — coreEndpoint.ts's ALLOWED_LOCAL_CORE_HOSTS).
test("#10158: local (127.0.0.1) http subscription URL is allowed by default", () => {
assert.equal(
isSubscriptionFetchUrlAllowed("http://127.0.0.1:8080/list"),
true,
"an operator should be able to fetch a proxy list from a local HTTP server"
);
});
test("#10158: IMDS / cloud-metadata pivot stays blocked even with local-first default", () => {
assert.equal(isSubscriptionFetchUrlAllowed("http://169.254.169.254/latest/meta-data/"), false);
});

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-"));
process.env.DATA_DIR = TEST_DATA_DIR;
@@ -229,3 +230,57 @@ test("global→rule switch re-evaluates binding: drops global, binds the selecte
assert.ok(afterRule, "rule mode should bind the node to provider provA");
assert.equal(afterRule?.proxy.host, "10.0.0.5");
});
// ─────────────────── Regression: #10158 local proxy subscription ───────────────────
// Promoted from the TDD probe (was RED on release/v3.8.50: createSubscription against a
// real local (127.0.0.1) HTTP server failed with "Fetch failed: Subscription URL is not
// allowed (scheme or host blocked)" even though coreEndpoint.ts already permits routing
// through a loopback core). Uses a REAL local HTTP server (not a fetch stub) so the fix
// is proven end-to-end through assertSafeFetchTarget's SSRF guard.
function startLocalSubscriptionServer(
body: string
): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve) => {
const srv = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(body);
});
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") throw new Error("no addr");
resolve({
url: `http://127.0.0.1:${addr.port}/list`,
close: () => new Promise((r) => srv.close(() => r())),
});
});
});
}
test("#10158: createSubscription against a real local (127.0.0.1) HTTP server syncs ok", async () => {
await reset();
const LIST_BODY = [
"proxies:",
" - name: local-node",
" type: http",
" server: 127.0.0.1",
" port: 8080",
].join("\n");
const { url, close } = await startLocalSubscriptionServer(LIST_BODY);
try {
const created = await sub.createSubscription({
name: "local-list",
url,
enabled: true,
mode: "global",
});
assert.equal(
created.status,
"ok",
`expected ok, got status=${created.status} error=${created.error}`
);
assert.ok((created.lastNodes ?? []).length >= 1, "expected at least one parsed node");
} finally {
await close();
}
});

View File

@@ -0,0 +1,120 @@
/**
* qwen-token-plan-console-site.test.ts — the personal Token Plan is sold through TWO
* consoles that share one backend, and the gateway validates the session against the
* console declared in the request. Sending the Alibaba console cookie with the
* QwenCloud console identity returns:
*
* {"errorCode":"BailianGateway.Login.NotLogined"}
*
* Verified live (2026-08-14) against both consoles: switching only consoleSite/domain/
* Origin/Referer (same cookie) turns that error into a real usage payload.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveConsoleSite,
fetchQwenTokenPlanQuota,
invalidateQwenTokenPlanQuotaCache,
} from "../../open-sse/services/qwenTokenPlanQuotaFetcher.ts";
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test("an Alibaba console cookie resolves to the Model Studio console", () => {
const site = resolveConsoleSite("cna=x; login_aliyunid_ticket=abc; aui=1", undefined);
assert.equal(site.consoleSite, "ALIYUN");
assert.equal(site.domain, "modelstudio.console.alibabacloud.com");
assert.ok(site.gatewayHost.includes("bailian-singapore-cs.alibabacloud.com"));
assert.ok(site.origin.includes("modelstudio.console.alibabacloud.com"));
});
test("a QwenCloud console cookie resolves to the QwenCloud console", () => {
const site = resolveConsoleSite("cna=x; login_qwencloud_ticket=abc", undefined);
assert.equal(site.consoleSite, "QWENCLOUD");
assert.equal(site.domain, "home.qwencloud.com");
assert.ok(site.gatewayHost.includes("cs-data.qwencloud.com"));
});
test("the provider decides when the cookie carries no console marker", () => {
assert.equal(resolveConsoleSite("session=opaque", "bailian-coding-plan").consoleSite, "ALIYUN");
assert.equal(
resolveConsoleSite("session=opaque", "qwen-cloud-token-plan").consoleSite,
"QWENCLOUD"
);
// Unknown provider + unmarked cookie keeps the QwenCloud default.
assert.equal(resolveConsoleSite("session=opaque", undefined).consoleSite, "QWENCLOUD");
});
test("fetch sends the Alibaba console identity for an aliyun cookie", async () => {
const connectionId = `console-site-${Date.now()}`;
const calls: { url: string; init?: RequestInit }[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, init });
const body = {
code: "200",
data: {
DataV2: {
data: {
code: "SUCCESS",
success: true,
data: url.includes("%2Fusage")
? { per1WeekPercentage: 0.32, per1WeekResetTime: 1787254140000 }
: url.includes("%2Fsubscription")
? { specCode: "pro" }
: { pro: { five_hour: 12000, weekly: 40000 } },
},
},
success: true,
},
httpStatusCode: "200",
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof globalThis.fetch;
const quota = await fetchQwenTokenPlanQuota(connectionId, {
provider: "bailian-coding-plan",
providerSpecificData: {
qwenCloudCookie: "cna=x; login_aliyunid_ticket=abc",
qwenCloudSecToken: "tok",
},
});
assert.ok(quota, "expected quota");
assert.equal(quota.percentUsed, 0.32);
const usageCall = calls.find((c) => c.url.includes("%2Fusage"));
assert.ok(usageCall, "usage call missing");
assert.ok(
usageCall.url.includes("bailian-singapore-cs.alibabacloud.com"),
`wrong gateway host: ${usageCall.url}`
);
const headers = usageCall.init?.headers as Record<string, string>;
assert.ok(String(headers.Referer).includes("modelstudio.console.alibabacloud.com"));
const params = JSON.parse(
new URLSearchParams(String(usageCall.init?.body)).get("params") ?? "{}"
);
assert.equal(params.Data.cornerstoneParam.consoleSite, "ALIYUN");
assert.equal(params.Data.cornerstoneParam.domain, "modelstudio.console.alibabacloud.com");
invalidateQwenTokenPlanQuotaCache(connectionId);
});
test("bailian-coding-plan points at the Token Plan endpoint, not the Coding Plan one", async () => {
const { bailian_coding_planProvider } =
await import("../../open-sse/config/providers/registry/bailian-coding-plan/index.ts");
// The catalog entry is named "Alibaba Token Plan" and links to token-plan-overview;
// coding-intl.dashscope.aliyuncs.com only accepts Coding Plan keys (401 otherwise).
assert.equal(
bailian_coding_planProvider.baseUrl,
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1"
);
});

View File

@@ -0,0 +1,101 @@
/**
* qwen-token-plan-cookie-field.test.ts — the Qwen Token Plan quota fetcher is
* cookie-authenticated (the inference API key cannot read the console gateway),
* so the connection modal MUST expose a field to paste that cookie. Without it
* the quota is unconfigurable from the dashboard.
*
* Mirrors the existing ollama-cloud / alibaba console-cookie fields.
*/
import test from "node:test";
import assert from "node:assert/strict";
// Imports the UI-free module on purpose: pulling the .tsx would drag in
// `@/shared/components` → untranspiled ESM (@lobehub/icons) that node:test
// cannot parse ("SyntaxError: Unexpected token 'export'").
import {
EMPTY_QUOTA_SCRAPING_FIELDS,
assignQuotaScrapingProviderData,
} from "../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts";
const { updateProviderConnectionSchema } = await import("../../src/shared/validation/schemas.ts");
test("qwen-cloud-token-plan persists the console cookie and optional sec_token", () => {
const target: Record<string, unknown> = {};
assignQuotaScrapingProviderData(
"qwen-cloud-token-plan",
{
...EMPTY_QUOTA_SCRAPING_FIELDS,
qwenCloudCookie: " token=abc123; aux=1 ",
qwenCloudSecToken: " sec-tok ",
},
target
);
assert.equal(target.qwenCloudCookie, "token=abc123; aux=1", "cookie must be stored trimmed");
assert.equal(target.qwenCloudSecToken, "sec-tok", "sec_token must be stored trimmed");
});
test("bailian-coding-plan reuses the same console cookie field", () => {
const target: Record<string, unknown> = {};
assignQuotaScrapingProviderData(
"bailian-coding-plan",
{ ...EMPTY_QUOTA_SCRAPING_FIELDS, qwenCloudCookie: "token=xyz" },
target
);
assert.equal(target.qwenCloudCookie, "token=xyz");
});
test("a blank cookie does not overwrite the stored one", () => {
const target: Record<string, unknown> = {};
assignQuotaScrapingProviderData(
"qwen-cloud-token-plan",
{ ...EMPTY_QUOTA_SCRAPING_FIELDS, qwenCloudCookie: " " },
target
);
assert.equal(
Object.hasOwn(target, "qwenCloudCookie"),
false,
"blank input must leave the stored cookie untouched"
);
});
test("a form object without the newer cookie fields does not throw", () => {
// Regression: adding bailian-coding-plan to the qwen branch made older callers
// (which build a partial form object) reach code that assumed the fields exist.
const target: Record<string, unknown> = {};
const partial = { ...EMPTY_QUOTA_SCRAPING_FIELDS } as Record<string, string>;
delete partial.qwenCloudCookie;
delete partial.qwenCloudSecToken;
for (const provider of ["bailian-coding-plan", "qwen-cloud-token-plan"]) {
assert.doesNotThrow(() =>
assignQuotaScrapingProviderData(
provider,
partial as unknown as typeof EMPTY_QUOTA_SCRAPING_FIELDS,
target
)
);
}
assert.equal(Object.hasOwn(target, "qwenCloudCookie"), false);
});
test("providerSpecificData validation guards the qwen cookie fields", () => {
const ok = updateProviderConnectionSchema.safeParse({
providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" },
});
assert.equal(ok.success, true, JSON.stringify(ok.error?.issues));
const wrongType = updateProviderConnectionSchema.safeParse({
providerSpecificData: { qwenCloudCookie: 42 },
});
assert.equal(wrongType.success, false, "non-string cookie must be rejected");
const tooLong = updateProviderConnectionSchema.safeParse({
providerSpecificData: { qwenCloudCookie: "x".repeat(10_001) },
});
assert.equal(tooLong.success, false, "oversized cookie must be rejected");
});

View 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"));
});