mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 11:43:10 +03:00
Compare commits
1 Commits
fix/10158-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0908ea5473 |
16
.env.example
16
.env.example
@@ -2040,22 +2040,6 @@ APP_LOG_TO_FILE=true
|
|||||||
# ALIBABA_CODING_PLAN_HOST=
|
# ALIBABA_CODING_PLAN_HOST=
|
||||||
# ALIBABA_CODING_PLAN_QUOTA_URL=
|
# 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 ──
|
# ── Alibaba Model Studio free-tier quota sync ──
|
||||||
# Console front-end path overrides for the free-tier quota fetcher. Used by:
|
# Console front-end path overrides for the free-tier quota fetcher. Used by:
|
||||||
# open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher
|
# open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher
|
||||||
|
|||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -372,7 +372,7 @@ jobs:
|
|||||||
- name: Upload Trivy SARIF to Security tab
|
- name: Upload Trivy SARIF to Security tab
|
||||||
if: needs.prepare.outputs.version != 'main'
|
if: needs.prepare.outputs.version != 'main'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: github/codeql-action/upload-sarif@v4.37.4
|
uses: github/codeql-action/upload-sarif@v4.37.6
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)
|
|
||||||
@@ -8435,12 +8435,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
description: >-
|
description: Redacted subscription URL.
|
||||||
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
mode:
|
mode:
|
||||||
|
|||||||
@@ -1142,10 +1142,6 @@ 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. |
|
| `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_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. |
|
| `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_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_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. |
|
| `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. |
|
||||||
|
|||||||
@@ -60,12 +60,7 @@ export const bailian_coding_planProvider: RegistryEntry = {
|
|||||||
alias: "bcp",
|
alias: "bcp",
|
||||||
format: "claude",
|
format: "claude",
|
||||||
executor: "default",
|
executor: "default",
|
||||||
// Token Plan endpoint (the catalog entry is "Alibaba Token Plan"). The former
|
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||||
// 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",
|
chatPath: "/messages",
|
||||||
authType: "apikey",
|
authType: "apikey",
|
||||||
authHeader: "x-api-key",
|
authHeader: "x-api-key",
|
||||||
|
|||||||
@@ -1,437 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -69,7 +69,6 @@ import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
|
|||||||
import { getGrokCliUsage } from "./usage/grokCli.ts";
|
import { getGrokCliUsage } from "./usage/grokCli.ts";
|
||||||
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
|
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
|
||||||
import { getCommandCodeUsage } from "./usage/command-code.ts";
|
import { getCommandCodeUsage } from "./usage/command-code.ts";
|
||||||
import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts";
|
|
||||||
import { getConolUsage } from "./conolUsage.ts";
|
import { getConolUsage } from "./conolUsage.ts";
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
@@ -112,7 +111,6 @@ export const USAGE_FETCHER_PROVIDERS = [
|
|||||||
"minimax-cn",
|
"minimax-cn",
|
||||||
"crof",
|
"crof",
|
||||||
"bailian-coding-plan",
|
"bailian-coding-plan",
|
||||||
"qwen-cloud-token-plan",
|
|
||||||
"nanogpt",
|
"nanogpt",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -204,8 +202,6 @@ export async function getUsageForProvider(
|
|||||||
return await getCrofUsage(apiKey || "");
|
return await getCrofUsage(apiKey || "");
|
||||||
case "bailian-coding-plan":
|
case "bailian-coding-plan":
|
||||||
return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData);
|
return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData);
|
||||||
case "qwen-cloud-token-plan":
|
|
||||||
return await getQwenTokenPlanUsage(id || "", apiKey || "", providerSpecificData);
|
|
||||||
case "nanogpt":
|
case "nanogpt":
|
||||||
return await getNanoGptUsage(apiKey || "");
|
return await getNanoGptUsage(apiKey || "");
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts";
|
import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts";
|
||||||
import { getQwenTokenPlanUsage } from "./qwen-token-plan.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bailian (Alibaba Token Plan) Usage
|
* Bailian (Alibaba Token Plan) Usage
|
||||||
@@ -22,25 +21,11 @@ export async function getBailianCodingPlanUsage(
|
|||||||
providerSpecificData?: Record<string, unknown>
|
providerSpecificData?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
try {
|
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 connection = { apiKey, providerSpecificData };
|
||||||
const quota = await fetchBailianQuota(connectionId, connection);
|
const quota = await fetchBailianQuota(connectionId, connection);
|
||||||
|
|
||||||
if (!quota) {
|
if (!quota) {
|
||||||
// Neither surface answered — surface the Token Plan guidance, which tells the
|
return { message: "Alibaba Token Plan connected. Unable to fetch quota." };
|
||||||
// operator how to supply the cookie the console gateway requires.
|
|
||||||
return tokenPlanUsage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bailianQuota = quota as BailianTripleWindowQuota;
|
const bailianQuota = quota as BailianTripleWindowQuota;
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -340,8 +340,6 @@ export default function EditConnectionModal({
|
|||||||
opencodeGoAuthCookie: "",
|
opencodeGoAuthCookie: "",
|
||||||
ollamaCloudUsageCookie: "",
|
ollamaCloudUsageCookie: "",
|
||||||
alibabaConsoleCookie: stringField(connection.providerSpecificData?.alibabaConsoleCookie),
|
alibabaConsoleCookie: stringField(connection.providerSpecificData?.alibabaConsoleCookie),
|
||||||
qwenCloudCookie: stringField(connection.providerSpecificData?.qwenCloudCookie),
|
|
||||||
qwenCloudSecToken: stringField(connection.providerSpecificData?.qwenCloudSecToken),
|
|
||||||
alibabaConsoleSecToken: stringField(
|
alibabaConsoleSecToken: stringField(
|
||||||
connection.providerSpecificData?.alibabaConsoleSecToken
|
connection.providerSpecificData?.alibabaConsoleSecToken
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,16 +3,44 @@
|
|||||||
import { Input } from "@/shared/components";
|
import { Input } from "@/shared/components";
|
||||||
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
|
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
|
||||||
|
|
||||||
import {
|
export type QuotaScrapingFieldValues = {
|
||||||
assignQuotaScrapingProviderData,
|
opencodeGoWorkspaceId: string;
|
||||||
EMPTY_QUOTA_SCRAPING_FIELDS,
|
opencodeGoAuthCookie: string;
|
||||||
QWEN_TOKEN_PLAN_PROVIDERS,
|
ollamaCloudUsageCookie: string;
|
||||||
type QuotaScrapingFieldValues,
|
alibabaConsoleCookie: string;
|
||||||
} from "./quotaScrapingFieldValues";
|
alibabaConsoleSecToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Re-exported so existing importers (modals, tests) keep their current paths.
|
export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = {
|
||||||
export { assignQuotaScrapingProviderData, EMPTY_QUOTA_SCRAPING_FIELDS };
|
opencodeGoWorkspaceId: "",
|
||||||
export type { QuotaScrapingFieldValues };
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type QuotaScrapingFieldsProps = {
|
type QuotaScrapingFieldsProps = {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -142,50 +170,5 @@ 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,57 +3,30 @@
|
|||||||
*
|
*
|
||||||
* The subscription URL is fetched server-side (see `subscriptionService
|
* The subscription URL is fetched server-side (see `subscriptionService
|
||||||
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
|
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
|
||||||
* subscription link — could point OmniRoute at cloud metadata (SSRF). Only
|
* subscription link — could point OmniRoute at internal services or cloud
|
||||||
* http/https to non-metadata hosts are allowed.
|
* metadata (SSRF). Only http/https to non-internal hosts are allowed:
|
||||||
*
|
* loopback / private / link-local (incl. 169.254.0.0/16 cloud metadata) /
|
||||||
* Local-first (#10158): OmniRoute already lets an operator route ALL traffic
|
* unspecified addresses are blocked.
|
||||||
* 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
|
* 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
|
* helpers here) so a hostname that resolves to an internal address is still
|
||||||
* refused. Splitting the logic into pure functions keeps it unit-testable
|
* refused. Splitting the logic into pure functions keeps it unit-testable
|
||||||
* without DNS / the full stack. No `@/`-aliased or DB-backed imports here —
|
* without DNS / the full stack.
|
||||||
* 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. */
|
/** Only these URL schemes may be used to *fetch* a subscription. */
|
||||||
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
|
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
|
||||||
|
|
||||||
// Blocked UNCONDITIONALLY, regardless of `allowLocal` — the classic SSRF→cloud
|
// Blocked IPv4 ranges (base, mask) as 32-bit ints.
|
||||||
// credential pivot; never a legitimate subscription source.
|
const BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
||||||
const ALWAYS_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
|
|
||||||
[0x00000000, 0xff000000], // 0.0.0.0/8 unspecified
|
[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
|
[0x7f000000, 0xff000000], // 127.0.0.0/8 loopback
|
||||||
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
|
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
|
||||||
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
|
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
|
||||||
[0xc0a80000, 0xffff0000], // 192.168.0.0/16 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})$/;
|
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||||
|
|
||||||
export function isIpv4Literal(host: string): boolean {
|
export function isIpv4Literal(host: string): boolean {
|
||||||
@@ -71,24 +44,20 @@ export function ipv4ToLong(host: string): number | null {
|
|||||||
return (parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]) >>> 0;
|
return (parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]) >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isIpv4Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
|
export function isIpv4Blocked(ip: string): boolean {
|
||||||
const allowLocal = opts.allowLocal ?? true;
|
|
||||||
const n = ipv4ToLong(ip);
|
const n = ipv4ToLong(ip);
|
||||||
if (n === null) return false;
|
if (n === null) return false;
|
||||||
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
|
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
|
||||||
// comparing so masked results with the high bit set aren't negative.
|
// comparing so masked results with the high bit set aren't negative.
|
||||||
const ranges = allowLocal ? ALWAYS_BLOCKED_IPV4 : [...ALWAYS_BLOCKED_IPV4, ...LOCAL_ONLY_BLOCKED_IPV4];
|
return BLOCKED_IPV4.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
|
||||||
return ranges.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Blocked IPv6 addresses: unspecified/link-local always; loopback/ULA only when strict. */
|
/** Blocked IPv6 addresses: loopback, unspecified, link-local, ULA. */
|
||||||
export function isIpv6Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
|
export function isIpv6Blocked(ip: string): boolean {
|
||||||
const allowLocal = opts.allowLocal ?? true;
|
|
||||||
const h = ip.toLowerCase();
|
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 === "::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
|
if (h.startsWith("fc") || h.startsWith("fd")) return true; // unique local
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -109,22 +78,20 @@ export function isIpLiteral(host: string): boolean {
|
|||||||
* the `dns` module convention (4 = IPv4, 6 = IPv6; missing ⇒ treat as v4).
|
* the `dns` module convention (4 = IPv4, 6 = IPv6; missing ⇒ treat as v4).
|
||||||
*/
|
*/
|
||||||
export function isAnyResolvedAddressBlocked(
|
export function isAnyResolvedAddressBlocked(
|
||||||
addrs: ReadonlyArray<{ address: string; family?: number }>,
|
addrs: ReadonlyArray<{ address: string; family?: number }>
|
||||||
opts: FetchGuardOptions = {}
|
|
||||||
): boolean {
|
): boolean {
|
||||||
return addrs.some(({ address, family }) => {
|
return addrs.some(({ address, family }) => {
|
||||||
const fam = family === 6 ? 6 : 4;
|
const fam = family === 6 ? 6 : 4;
|
||||||
return fam === 6 ? isIpv6Blocked(address, opts) : isIpv4Blocked(address, opts);
|
return fam === 6 ? isIpv6Blocked(address) : isIpv4Blocked(address);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structural check (no DNS). True only if the scheme is allowed AND, when the
|
* 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 for the given
|
* host is an IP literal, it is not in a blocked range. Hostnames pass the
|
||||||
* `allowLocal` mode. Hostnames pass the structural check — they are resolved
|
* structural check — they are resolved and re-checked at fetch time.
|
||||||
* and re-checked at fetch time.
|
|
||||||
*/
|
*/
|
||||||
export function isSubscriptionFetchUrlAllowed(url: string, opts: FetchGuardOptions = {}): boolean {
|
export function isSubscriptionFetchUrlAllowed(url: string): boolean {
|
||||||
let u: URL;
|
let u: URL;
|
||||||
try {
|
try {
|
||||||
u = new URL(url);
|
u = new URL(url);
|
||||||
@@ -137,8 +104,8 @@ export function isSubscriptionFetchUrlAllowed(url: string, opts: FetchGuardOptio
|
|||||||
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
|
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
|
||||||
if (host === "") return false;
|
if (host === "") return false;
|
||||||
if (isIpLiteral(host)) {
|
if (isIpLiteral(host)) {
|
||||||
if (isIpv4Literal(host)) return !isIpv4Blocked(host, opts);
|
if (isIpv4Literal(host)) return !isIpv4Blocked(host);
|
||||||
return !isIpv6Blocked(host, opts);
|
return !isIpv6Blocked(host);
|
||||||
}
|
}
|
||||||
return true; // hostname: resolved + checked at fetch time
|
return true; // hostname: resolved + checked at fetch time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ import {
|
|||||||
isSubscriptionFetchUrlAllowed,
|
isSubscriptionFetchUrlAllowed,
|
||||||
isIpLiteral,
|
isIpLiteral,
|
||||||
isAnyResolvedAddressBlocked,
|
isAnyResolvedAddressBlocked,
|
||||||
type FetchGuardOptions,
|
|
||||||
} from "./fetchGuard";
|
} from "./fetchGuard";
|
||||||
import { areLocalProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
|
|
||||||
import { withRetry } from "./fetchRetry";
|
import { withRetry } from "./fetchRetry";
|
||||||
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
|
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
|
||||||
|
|
||||||
@@ -282,20 +280,13 @@ export async function deleteSubscription(id: string): Promise<boolean> {
|
|||||||
// ───────────────────────────── Sync + apply ─────────────────────────────
|
// ───────────────────────────── Sync + apply ─────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refuse to fetch a subscription URL unless it is http/https to an allowed
|
* Refuse to fetch a subscription URL unless it is http/https to a non-internal
|
||||||
* host. IP literals are checked structurally; hostnames are resolved and the
|
* host. IP literals are checked structurally; hostnames are resolved and the
|
||||||
* resolved addresses are re-checked (fail closed on resolution errors).
|
* resolved addresses are re-checked (fail closed on resolution errors). This
|
||||||
*
|
* blocks SSRF to internal services / cloud metadata (169.254.169.254).
|
||||||
* 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> {
|
async function assertSafeFetchTarget(url: string): Promise<void> {
|
||||||
const guardOpts: FetchGuardOptions = { allowLocal: areLocalProviderUrlsAllowed() };
|
if (!isSubscriptionFetchUrlAllowed(url)) {
|
||||||
if (!isSubscriptionFetchUrlAllowed(url, guardOpts)) {
|
|
||||||
throw new Error("Subscription URL is not allowed (scheme or host blocked)");
|
throw new Error("Subscription URL is not allowed (scheme or host blocked)");
|
||||||
}
|
}
|
||||||
const host = new URL(url).hostname.toLowerCase();
|
const host = new URL(url).hostname.toLowerCase();
|
||||||
@@ -308,7 +299,7 @@ async function assertSafeFetchTarget(url: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const dns = await import("node:dns");
|
const dns = await import("node:dns");
|
||||||
const addrs = await dns.promises.lookup(bare, { all: true });
|
const addrs = await dns.promises.lookup(bare, { all: true });
|
||||||
if (isAnyResolvedAddressBlocked(addrs, guardOpts)) {
|
if (isAnyResolvedAddressBlocked(addrs)) {
|
||||||
throw new Error("Subscription host resolves to a blocked (internal) address");
|
throw new Error("Subscription host resolves to a blocked (internal) address");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -97,9 +97,6 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
|||||||
"command-code",
|
"command-code",
|
||||||
"conol-web",
|
"conol-web",
|
||||||
"cnl",
|
"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 DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
|
||||||
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
|
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
|
||||||
|
|||||||
@@ -500,10 +500,6 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
|||||||
"command-code",
|
"command-code",
|
||||||
"conol-web",
|
"conol-web",
|
||||||
"cnl",
|
"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) ──
|
// ── Zod validation at module load (Phase 7.2) ──
|
||||||
|
|||||||
@@ -328,8 +328,6 @@ export function validateProviderSpecificData(
|
|||||||
"usageCookie",
|
"usageCookie",
|
||||||
"alibabaConsoleCookie",
|
"alibabaConsoleCookie",
|
||||||
"alibabaConsoleSecToken",
|
"alibabaConsoleSecToken",
|
||||||
"qwenCloudCookie",
|
|
||||||
"qwenCloudSecToken",
|
|
||||||
] as const) {
|
] as const) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ import {
|
|||||||
registerCodexQuotaFetcher,
|
registerCodexQuotaFetcher,
|
||||||
} from "@omniroute/open-sse/services/codexQuotaFetcher.ts";
|
} from "@omniroute/open-sse/services/codexQuotaFetcher.ts";
|
||||||
import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/services/bailianQuotaFetcher.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 { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts";
|
||||||
import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts";
|
import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts";
|
||||||
import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts";
|
import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts";
|
||||||
@@ -172,11 +171,6 @@ registerCodexQuotaFetcher();
|
|||||||
// can proactively switch accounts before quota is exhausted.
|
// can proactively switch accounts before quota is exhausted.
|
||||||
registerBailianCodingPlanQuotaFetcher();
|
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).
|
// Register CrofAI usage fetcher (subscription requests + credits balance).
|
||||||
// Surfaces usable_requests + credits in the monitor and only blocks (preflight
|
// Surfaces usable_requests + credits in the monitor and only blocks (preflight
|
||||||
// opt-in) when the active bucket reaches zero.
|
// opt-in) when the active bucket reaches zero.
|
||||||
|
|||||||
@@ -23,25 +23,9 @@ test("non-http(s) schemes are rejected", () => {
|
|||||||
assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false);
|
assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("local-first (#10158): loopback/private IPv4 literals are ALLOWED by default", () => {
|
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"]) {
|
|
||||||
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"]) {
|
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(
|
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), false, ip);
|
||||||
isSubscriptionFetchUrlAllowed(`https://${ip}/x`, { allowLocal: false }),
|
|
||||||
false,
|
|
||||||
ip
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,25 +34,9 @@ test("public IPv4 literals are allowed", () => {
|
|||||||
assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true);
|
assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("local-first (#10158): loopback/ULA IPv6 literals are ALLOWED by default", () => {
|
test("blocked IPv6 literals are rejected (bracketed)", () => {
|
||||||
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"]) {
|
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
|
||||||
assert.equal(
|
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), false, ip);
|
||||||
isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`, { allowLocal: false }),
|
|
||||||
false,
|
|
||||||
ip
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,13 +46,11 @@ test("malformed / empty-host URLs are rejected", () => {
|
|||||||
assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host
|
assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ip-range + literal helpers (local-first defaults)", () => {
|
test("ip-range + literal helpers", () => {
|
||||||
assert.equal(isIpv4Blocked("127.0.0.1"), false); // allowed by default (local-first)
|
assert.equal(isIpv4Blocked("127.0.0.1"), true);
|
||||||
assert.equal(isIpv4Blocked("127.0.0.1", { allowLocal: false }), true);
|
assert.equal(isIpv4Blocked("169.254.169.254"), true);
|
||||||
assert.equal(isIpv4Blocked("169.254.169.254"), true); // always blocked
|
|
||||||
assert.equal(isIpv4Blocked("8.8.8.8"), false);
|
assert.equal(isIpv4Blocked("8.8.8.8"), false);
|
||||||
assert.equal(isIpv6Blocked("::1"), false); // allowed by default (local-first)
|
assert.equal(isIpv6Blocked("::1"), true);
|
||||||
assert.equal(isIpv6Blocked("::1", { allowLocal: false }), true);
|
|
||||||
assert.equal(isIpv6Blocked("2606:4700::1111"), false);
|
assert.equal(isIpv6Blocked("2606:4700::1111"), false);
|
||||||
assert.equal(isIpLiteral("127.0.0.1"), true);
|
assert.equal(isIpLiteral("127.0.0.1"), true);
|
||||||
assert.equal(isIpLiteral("::1"), true);
|
assert.equal(isIpLiteral("::1"), true);
|
||||||
@@ -92,22 +58,14 @@ test("ip-range + literal helpers (local-first defaults)", () => {
|
|||||||
assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]);
|
assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multi-record DNS: blocks if ANY resolved address is metadata/link-local (local-first default)", () => {
|
test("multi-record DNS: blocks if ANY resolved address is internal", () => {
|
||||||
// A private address alongside a public one is now ALLOWED by default
|
// Hostname resolves to a public AND a private address — must be refused
|
||||||
// (local-first) — only cloud-metadata/link-local addresses stay blocked.
|
// (closes the first-address-only bypass).
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isAnyResolvedAddressBlocked([
|
isAnyResolvedAddressBlocked([
|
||||||
{ address: "8.8.8.8", family: 4 },
|
{ address: "8.8.8.8", family: 4 },
|
||||||
{ address: "192.168.1.10", 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
|
true
|
||||||
);
|
);
|
||||||
// All public → allowed.
|
// All public → allowed.
|
||||||
@@ -118,45 +76,14 @@ test("multi-record DNS: blocks if ANY resolved address is metadata/link-local (l
|
|||||||
]),
|
]),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
// Strict mode (allowLocal: false): a private address is blocked again.
|
// A single internal IPv6 among public records → blocked.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isAnyResolvedAddressBlocked(
|
isAnyResolvedAddressBlocked([
|
||||||
[
|
{ address: "2606:4700::1111", family: 6 },
|
||||||
{ address: "8.8.8.8", family: 4 },
|
{ address: "fd00::1", family: 6 },
|
||||||
{ 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
|
true
|
||||||
);
|
);
|
||||||
// Empty result set → nothing blocked.
|
// Empty result set → nothing blocked.
|
||||||
assert.equal(isAnyResolvedAddressBlocked([]), false);
|
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import http from "node:http";
|
|
||||||
|
|
||||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-"));
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-"));
|
||||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||||
@@ -230,57 +229,3 @@ 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.ok(afterRule, "rule mode should bind the node to provider provA");
|
||||||
assert.equal(afterRule?.proxy.host, "10.0.0.5");
|
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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");
|
|
||||||
});
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* qwen-token-plan-quota-fetcher.test.ts — Qwen Cloud / Alibaba Model Studio personal
|
|
||||||
* Token Plan quota fetcher (issue #9603, Problema 1: quota is missing).
|
|
||||||
*
|
|
||||||
* Fixtures captured live (2026-08-13) from home.qwencloud.com/billing/subscription/
|
|
||||||
* token-plan-individual — console gateway POST cs-data.qwencloud.com/data/api.json
|
|
||||||
* (action=IntlBroadScopeAspnGateway, product=sfm_bailian), cookie-authenticated.
|
|
||||||
*/
|
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import {
|
|
||||||
QWEN_TOKEN_PLAN_WINDOW_5H,
|
|
||||||
QWEN_TOKEN_PLAN_WINDOW_WEEKLY,
|
|
||||||
extractQwenSecToken,
|
|
||||||
fetchQwenTokenPlanQuota,
|
|
||||||
invalidateQwenTokenPlanQuotaCache,
|
|
||||||
registerQwenTokenPlanQuotaFetcher,
|
|
||||||
} from "../../open-sse/services/qwenTokenPlanQuotaFetcher.ts";
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
const RESET_MS = 1786714740000; // 2026-08-14 10:39 (captured per1WeekResetTime)
|
|
||||||
|
|
||||||
type FetchCall = { url: string; init: RequestInit | undefined };
|
|
||||||
|
|
||||||
function gatewayBody(payload: unknown, api: string): string {
|
|
||||||
return JSON.stringify({
|
|
||||||
code: "200",
|
|
||||||
data: {
|
|
||||||
DataV2: {
|
|
||||||
ret: ["SUCCESS::ok"],
|
|
||||||
data: { msg: "Success.", code: "SUCCESS", data: payload, success: true },
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
httpStatus: 200,
|
|
||||||
errorCode: "",
|
|
||||||
api,
|
|
||||||
errorMsg: "",
|
|
||||||
},
|
|
||||||
httpStatusCode: "200",
|
|
||||||
successResponse: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const USAGE_PAYLOAD = { per1WeekResetTime: RESET_MS, per1WeekPercentage: 0.55 };
|
|
||||||
const QUOTA_CONFIG_PAYLOAD = {
|
|
||||||
standard: { five_hour: 3000.0, weekly: 10000.0 },
|
|
||||||
addon_quota: { extrabundle: 20000.0 },
|
|
||||||
lite: { five_hour: 700.0, weekly: 2500.0 },
|
|
||||||
pro: { five_hour: 12000.0, weekly: 40000.0 },
|
|
||||||
};
|
|
||||||
const SUBSCRIPTION_PAYLOAD = {
|
|
||||||
instanceCode: "sfm_tokenplansolo_public_intl-sg-test",
|
|
||||||
specCode: "pro",
|
|
||||||
remainingDays: 24,
|
|
||||||
startTime: 1786109803000,
|
|
||||||
endTime: 1788796800000,
|
|
||||||
autoRenewFlag: false,
|
|
||||||
status: "VALID",
|
|
||||||
};
|
|
||||||
|
|
||||||
function mockGateway(
|
|
||||||
calls: FetchCall[],
|
|
||||||
overrides?: { usagePayload?: unknown; dashboardHtml?: string }
|
|
||||||
): void {
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
calls.push({ url, init });
|
|
||||||
|
|
||||||
if (!url.includes("/data/api.json")) {
|
|
||||||
// Dashboard HTML fetch (sec_token resolution)
|
|
||||||
return new Response(overrides?.dashboardHtml ?? "<html>no token here</html>", {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "text/html" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonHeaders = { "content-type": "application/json" };
|
|
||||||
if (url.includes("%2Fusage")) {
|
|
||||||
const payload =
|
|
||||||
overrides && "usagePayload" in overrides ? overrides.usagePayload : USAGE_PAYLOAD;
|
|
||||||
return new Response(gatewayBody(payload, "usage"), { status: 200, headers: jsonHeaders });
|
|
||||||
}
|
|
||||||
if (url.includes("%2Fquota-config")) {
|
|
||||||
return new Response(gatewayBody(QUOTA_CONFIG_PAYLOAD, "quota-config"), {
|
|
||||||
status: 200,
|
|
||||||
headers: jsonHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.includes("%2Fsubscription")) {
|
|
||||||
return new Response(gatewayBody(SUBSCRIPTION_PAYLOAD, "subscription"), {
|
|
||||||
status: 200,
|
|
||||||
headers: jsonHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ code: "404" }), { status: 404, headers: jsonHeaders });
|
|
||||||
}) as typeof globalThis.fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.beforeEach(() => {
|
|
||||||
delete process.env.QWEN_CLOUD_COOKIE;
|
|
||||||
delete process.env.QWEN_CLOUD_SEC_TOKEN;
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetchQwenTokenPlanQuota returns null without any cookie configured", async () => {
|
|
||||||
const calls: FetchCall[] = [];
|
|
||||||
mockGateway(calls);
|
|
||||||
|
|
||||||
const quota = await fetchQwenTokenPlanQuota(`qwen-nocookie-${Date.now()}`, {});
|
|
||||||
|
|
||||||
assert.equal(quota, null);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetchQwenTokenPlanQuota parses the captured weekly-only usage response", async () => {
|
|
||||||
const connectionId = `qwen-weekly-${Date.now()}`;
|
|
||||||
const calls: FetchCall[] = [];
|
|
||||||
mockGateway(calls);
|
|
||||||
|
|
||||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
|
||||||
providerSpecificData: { qwenCloudCookie: "token=abc123; aux=1", qwenCloudSecToken: "sec-tok" },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(quota, "expected quota, got null");
|
|
||||||
assert.equal(quota.percentUsed, 0.55);
|
|
||||||
assert.equal(quota.resetAt, new Date(RESET_MS).toISOString());
|
|
||||||
|
|
||||||
const windows = (
|
|
||||||
quota as { windows: Record<string, { percentUsed: number; resetAt: string | null }> }
|
|
||||||
).windows;
|
|
||||||
assert.ok(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY], "weekly window missing");
|
|
||||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY].percentUsed, 0.55);
|
|
||||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY].resetAt, new Date(RESET_MS).toISOString());
|
|
||||||
// 5-hour window "Temporarily Removed" → API omits per5Hour* fields → no window
|
|
||||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_5H], undefined);
|
|
||||||
|
|
||||||
// Tier totals resolved via subscription.specCode → quota-config.pro
|
|
||||||
assert.equal(quota.total, 40000);
|
|
||||||
assert.equal(quota.used, Math.round(0.55 * 40000));
|
|
||||||
assert.equal((quota as { specCode: string | null }).specCode, "pro");
|
|
||||||
|
|
||||||
// Request contract (captured shape)
|
|
||||||
const usageCall = calls.find((c) => c.url.includes("%2Fusage"));
|
|
||||||
assert.ok(usageCall, "usage gateway call missing");
|
|
||||||
assert.equal(usageCall.init?.method, "POST");
|
|
||||||
const headers = usageCall.init?.headers as Record<string, string>;
|
|
||||||
assert.ok(String(headers["Cookie"] ?? headers["cookie"]).includes("token=abc123"));
|
|
||||||
const body = String(usageCall.init?.body);
|
|
||||||
assert.ok(body.includes("product=sfm_bailian"), "body missing product");
|
|
||||||
assert.ok(body.includes("action=IntlBroadScopeAspnGateway"), "body missing action");
|
|
||||||
assert.ok(body.includes("region=ap-southeast-1"), "body missing region");
|
|
||||||
assert.ok(body.includes("sec_token=sec-tok"), "body missing sec_token");
|
|
||||||
const params = new URLSearchParams(body).get("params");
|
|
||||||
assert.ok(params, "body missing params");
|
|
||||||
const parsedParams = JSON.parse(params) as {
|
|
||||||
Api: string;
|
|
||||||
V: string;
|
|
||||||
Data: { commodityCode: string };
|
|
||||||
};
|
|
||||||
assert.equal(parsedParams.V, "1.0");
|
|
||||||
assert.ok(parsedParams.Api.includes("/tokenplan/personal/api/v2/usage"));
|
|
||||||
assert.equal(parsedParams.Data.commodityCode, "sfm_tokenplansolo_public_intl");
|
|
||||||
|
|
||||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetchQwenTokenPlanQuota includes the 5-hour window when the API returns it", async () => {
|
|
||||||
const connectionId = `qwen-5h-${Date.now()}`;
|
|
||||||
const calls: FetchCall[] = [];
|
|
||||||
mockGateway(calls, {
|
|
||||||
usagePayload: {
|
|
||||||
per1WeekResetTime: RESET_MS,
|
|
||||||
per1WeekPercentage: 0.55,
|
|
||||||
per5HourResetTime: RESET_MS - 3_600_000,
|
|
||||||
per5HourPercentage: 0.7,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
|
||||||
providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(quota, "expected quota, got null");
|
|
||||||
const windows = (
|
|
||||||
quota as { windows: Record<string, { percentUsed: number; resetAt: string | null }> }
|
|
||||||
).windows;
|
|
||||||
assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_5H]?.percentUsed, 0.7);
|
|
||||||
// worst window wins
|
|
||||||
assert.equal(quota.percentUsed, 0.7);
|
|
||||||
assert.equal(quota.resetAt, new Date(RESET_MS - 3_600_000).toISOString());
|
|
||||||
|
|
||||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetchQwenTokenPlanQuota returns null when the console session expired", async () => {
|
|
||||||
const connectionId = `qwen-expired-${Date.now()}`;
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
new Response(JSON.stringify({ code: "ConsoleNeedLogin" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
})) as typeof globalThis.fetch;
|
|
||||||
|
|
||||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
|
||||||
providerSpecificData: { qwenCloudCookie: "token=stale", qwenCloudSecToken: "sec-tok" },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(quota, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetchQwenTokenPlanQuota resolves sec_token from the dashboard when absent", async () => {
|
|
||||||
const connectionId = `qwen-sectoken-${Date.now()}`;
|
|
||||||
const calls: FetchCall[] = [];
|
|
||||||
mockGateway(calls, {
|
|
||||||
dashboardHtml:
|
|
||||||
'<script>window.X = { IS_CERTIFIED: "true", SEC_TOKEN: "resolved-tok" };</script>',
|
|
||||||
});
|
|
||||||
|
|
||||||
const quota = await fetchQwenTokenPlanQuota(connectionId, {
|
|
||||||
providerSpecificData: { qwenCloudCookie: "token=abc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(quota, "expected quota, got null");
|
|
||||||
const dashboardCall = calls.find((c) => !c.url.includes("/data/api.json"));
|
|
||||||
assert.ok(dashboardCall, "dashboard fetch for sec_token missing");
|
|
||||||
const usageCall = calls.find((c) => c.url.includes("%2Fusage"));
|
|
||||||
assert.ok(String(usageCall?.init?.body).includes("sec_token=resolved-tok"));
|
|
||||||
|
|
||||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetchQwenTokenPlanQuota serves the second call from cache", async () => {
|
|
||||||
const connectionId = `qwen-cache-${Date.now()}`;
|
|
||||||
const calls: FetchCall[] = [];
|
|
||||||
mockGateway(calls);
|
|
||||||
|
|
||||||
const connection = {
|
|
||||||
providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" },
|
|
||||||
};
|
|
||||||
const first = await fetchQwenTokenPlanQuota(connectionId, connection);
|
|
||||||
assert.ok(first);
|
|
||||||
const callCountAfterFirst = calls.length;
|
|
||||||
|
|
||||||
const second = await fetchQwenTokenPlanQuota(connectionId, connection);
|
|
||||||
assert.ok(second);
|
|
||||||
assert.equal(calls.length, callCountAfterFirst);
|
|
||||||
|
|
||||||
invalidateQwenTokenPlanQuotaCache(connectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extractQwenSecToken pulls SEC_TOKEN out of dashboard HTML", () => {
|
|
||||||
assert.equal(extractQwenSecToken('foo SEC_TOKEN: "abc-123", bar'), "abc-123");
|
|
||||||
assert.equal(extractQwenSecToken("<html>nothing</html>"), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("registerQwenTokenPlanQuotaFetcher registers without throwing", () => {
|
|
||||||
registerQwenTokenPlanQuotaFetcher();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("qwen-cloud-token-plan and bailian-coding-plan are wired into the usage/UI lists", async () => {
|
|
||||||
const { USAGE_FETCHER_PROVIDERS } = await import("../../open-sse/services/usage.ts");
|
|
||||||
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
|
||||||
|
|
||||||
assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("qwen-cloud-token-plan"));
|
|
||||||
assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("qwen-cloud-token-plan"));
|
|
||||||
// #9603 UI gap: coding-plan connections were filtered out of /dashboard/quota
|
|
||||||
assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("bailian-coding-plan"));
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user