diff --git a/.env.example b/.env.example index 319da186e8b4..e488ffa5e5fb 100644 --- a/.env.example +++ b/.env.example @@ -2815,6 +2815,23 @@ APP_LOG_TO_FILE=true # QWEN_TOKEN_PLAN_HOST= # QWEN_TOKEN_PLAN_DASHBOARD_URL= +# ── Xiaomi MiMo Token Plan quota ── +# Console-cookie live fetch + self-tracked fallback (issue #15753). Used by: +# open-sse/services/usage/xiaomi-mimo.ts. Prefer the per-connection Dashboard +# fields (xiaomiMimoConsoleCookie / monthlyTokenLimit) — these env vars are +# global fallbacks. The cookie is a SENSITIVE session credential. +# Getting the cookie: log in to platform.xiaomimimo.com, open the Token Plan +# page, press F12 > Network, reload, click any request to +# platform.xiaomimimo.com and copy the WHOLE Cookie value from Request Headers +# (it contains api-platform_serviceToken and userId). 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. +# XIAOMI_MIMO_CONSOLE_COOKIE= +# Monthly budget (Credits) for the OmniRoute-tracked fallback without a cookie. +# Default: 4100000000 (Lite tier). Standard 11000000000 / Pro 38000000000 / +# Max 82000000000. +# XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT= + # ── Alibaba Model Studio free-tier quota sync ── # Console front-end path overrides for the free-tier quota fetcher. Used by: # open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher diff --git a/changelog.d/fixes/15753-xiaomi-mimo-live-token-plan-quota.md b/changelog.d/fixes/15753-xiaomi-mimo-live-token-plan-quota.md new file mode 100644 index 000000000000..f5f356bfd0b8 --- /dev/null +++ b/changelog.d/fixes/15753-xiaomi-mimo-live-token-plan-quota.md @@ -0,0 +1 @@ +- **fix(providers):** Xiaomi MiMo quota now reads the real Token Plan usage from the MiMo console API (plan label, used/limit Credits, `currentPeriodEnd` reset) through a new per-connection console-cookie field, and the self-tracked fallback budget becomes configurable (`providerSpecificData.monthlyTokenLimit` / `XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT`, default 4.1B Lite Credits) — no more permanent `Exhausted` after a plan upgrade ([#15753](https://github.com/diegosouzapw/OmniRoute/issues/15753)) — thanks @LazyGatto diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 174ca90cb6e0..e2ceb8d9518d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1435,6 +1435,8 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `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. | +| `XIAOMI_MIMO_CONSOLE_COOKIE` | _(unset)_ | `open-sse/services/usage/xiaomi-mimo.ts` | Console session cookie for the Xiaomi MiMo Token Plan quota API (`platform.xiaomimimo.com/api/v1/tokenPlan/usage` + `tokenPlan/detail` — the inference API key cannot read it). Copy the whole `Cookie` request header — it contains `api-platform_serviceToken` and `userId` — from any request to `platform.xiaomimimo.com` on the Token Plan page (F12 › Network). Sensitive and session-scoped; prefer the per-connection `xiaomiMimoConsoleCookie` Dashboard field. | +| `XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT` | `4100000000` | `open-sse/services/usage/xiaomi-mimo.ts` | Monthly budget (Credits) for the OmniRoute-tracked Xiaomi MiMo fallback used when no console cookie is configured. Defaults to the Lite tier's 4.1B; set it to the plan's Credits (Standard 11B / Pro 38B / Max 82B). A per-connection `providerSpecificData.monthlyTokenLimit` override wins over this env var. | | `ALIBABA_FREE_TIER_VISION_FE_PATH` | `/costing-balance/free-quota-image-video` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier vision/media quota. | | `ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH` | `/costing-balance/free-quota-multimodal` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier multimodal quota. | | `ALIBABA_FREE_TIER_AUDIO_FE_PATH` | `/costing-balance/free-quota-audio` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier audio quota. | diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 077533ae0c0c..701cabc94196 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -214,9 +214,9 @@ export async function getUsageForProvider( case "opencode-zen": return await getOpencodeUsage(id || "", apiKey || ""); case "xiaomi-mimo": - return await getXiaomiMimoUsage(id || ""); + return await getXiaomiMimoUsage(id || "", "xiaomi-mimo", providerSpecificData); case "xiaomi-mimo-token-plan": - return await getXiaomiMimoUsage(id || "", "xiaomi-mimo-token-plan"); + return await getXiaomiMimoUsage(id || "", "xiaomi-mimo-token-plan", providerSpecificData); case "xai": return await getXaiUsage(id || ""); case "xai-oauth": diff --git a/open-sse/services/usage/xiaomi-mimo.ts b/open-sse/services/usage/xiaomi-mimo.ts index 94666168899b..9a020b4f9722 100644 --- a/open-sse/services/usage/xiaomi-mimo.ts +++ b/open-sse/services/usage/xiaomi-mimo.ts @@ -1,51 +1,251 @@ /** - * usage/xiaomi-mimo.ts — Xiaomi MiMo self-tracked monthly quota fetcher. + * usage/xiaomi-mimo.ts — Xiaomi MiMo Token Plan quota fetcher (issue #15753). * - * Extracted from services/usage.ts (god-file decomposition): the Xiaomi MiMo family — - * Xiaomi exposes plan usage only behind the console session cookie (the API key - * cannot reach the `tokenPlan/usage` endpoint), so OmniRoute self-tracks the - * tokens it routed to the connection in the current UTC month (from usage_history) - * and compares them to the known Token Plan monthly limit. Depends only on the - * sibling scalar/quota leaves + the usageStats dynamic import — no host coupling - * — so it lives as a co-located provider leaf. usage.ts imports getXiaomiMimoUsage - * (dispatcher + __testing). Behavior-preserving move. + * Two layers: + * + * 1. Live console fetch (preferred): when the connection carries a MiMo console + * session cookie (providerSpecificData.xiaomiMimoConsoleCookie / `cookie`, or + * XIAOMI_MIMO_CONSOLE_COOKIE), GET the console Token Plan API — + * `/api/v1/tokenPlan/usage` (monthUsage items: used/limit in Credits) and + * `/api/v1/tokenPlan/detail` (planName + currentPeriodEnd) — and report the + * real plan window. The `tp-`/`mk-` inference key cannot read this: the + * token-plan hosts only serve `/v1/*`, and the console endpoints answer + * 401 + Xiaomi SSO `loginUrl` without the session cookie. + * + * 2. Self-tracked fallback (keeps working without a cookie): count the tokens + * OmniRoute routed to the connection in the current UTC month + * (usage_history) and compare them to a configurable monthly budget — + * providerSpecificData.monthlyTokenLimit, then XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT, + * then 4.1B (the Lite plan's Credits). When the console session is missing + * or expired, the fallback result carries a hint message so the dashboard + * tells the operator how to enable live quota instead of failing hard. + * + * Depends only on the sibling scalar/quota leaves + the quotaFetchThrottle gate + * + the usageStats dynamic import — no host coupling. usage.ts imports + * getXiaomiMimoUsage (dispatcher + __testing). */ -import { createQuotaFromUsage } from "./quota.ts"; +import { createQuotaFromUsage, parseResetTime, type UsageQuota } from "./quota.ts"; +import { toRecord, toNumber, toTitleCase } from "./scalars.ts"; +import { throttleQuotaFetch } from "../quotaFetchThrottle.ts"; + +type JsonRecord = Record; + +// Xiaomi MiMo Token Plan monthly limit (tokens) for the Lite tier. Keep in sync +// with the "xiaomi-mimo" preset in src/lib/quota/planRegistry.ts. +const DEFAULT_MONTHLY_TOKEN_LIMIT = 4_100_000_000; + +const MIMO_CONSOLE_BASE_URL = "https://platform.xiaomimimo.com/api/v1"; +const CONSOLE_FETCH_TIMEOUT_MS = 8_000; + +/** providerSpecificData keys scanned for the console `Cookie:` value (first hit wins). */ +const CONSOLE_COOKIE_KEYS = ["xiaomiMimoConsoleCookie", "cookie"] as const; -// Xiaomi MiMo Token Plan monthly limit (tokens). Keep in sync with the -// "xiaomi-mimo" preset in src/lib/quota/planRegistry.ts. -const XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT = 4_100_000_000; +const PLAN_LABEL_TRACKED = "Xiaomi MiMo Token Plan (OmniRoute-tracked)"; +const PLAN_LABEL_LIVE = "Xiaomi MiMo Token Plan"; + +const CONSOLE_COOKIE_HINT = + "MiMo console cookie missing or expired — set the connection's 'Xiaomi MiMo console cookie' " + + "field (the Xiaomi SSO session Cookie from platform.xiaomimimo.com containing " + + "api-platform_serviceToken and userId), or set XIAOMI_MIMO_CONSOLE_COOKIE. The tp-/mk- " + + "inference key cannot read Token Plan usage."; + +// ─── Config helpers ───────────────────────────────────────────────────────── + +function getConsoleCookie(providerSpecificData?: JsonRecord): string { + for (const key of CONSOLE_COOKIE_KEYS) { + const value = providerSpecificData?.[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return process.env.XIAOMI_MIMO_CONSOLE_COOKIE?.trim() || ""; +} /** - * Xiaomi MiMo — SELF-TRACKED monthly quota. - * - * Xiaomi exposes plan usage only behind the console session cookie (the API key - * cannot reach the `tokenPlan/usage` endpoint), so there is no upstream usage - * API to call. Instead we count the tokens OmniRoute itself routed to this - * connection in the current UTC month (from `usage_history`) and compare them - * to the known Token Plan monthly limit. This reflects only traffic that went - * through OmniRoute, not the provider's own dashboard figure. + * Monthly budget for the self-tracked fallback: the connection override, then + * the env override, then the historical 4.1B Lite-tier default. + */ +export function resolveMonthlyTokenLimit(providerSpecificData?: JsonRecord): number { + const configured = toNumber(providerSpecificData?.monthlyTokenLimit, 0); + if (configured > 0) return configured; + const envLimit = Number(process.env.XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT?.trim()); + if (Number.isFinite(envLimit) && envLimit > 0) return envLimit; + return DEFAULT_MONTHLY_TOKEN_LIMIT; +} + +function nextMonthStartUtc(): string { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString(); +} + +/** + * The console reports `currentPeriodEnd` as a naive "YYYY-MM-DD HH:mm:ss" wall + * time with no zone info — parse it deterministically as UTC. Anything else + * falls through to the shared reset-time parser (epoch, ISO, Date, …). */ -export async function getXiaomiMimoUsage(connectionId: string, provider = "xiaomi-mimo") { +export function parseConsoleTime(value: unknown): string | null { + if (typeof value !== "string") return parseResetTime(value); + const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(value.trim()); + if (!match) return parseResetTime(value); + const [, year, month, day, hour, minute, second] = match; + return new Date( + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) + ).toISOString(); +} + +// ─── Console transport ────────────────────────────────────────────────────── + +interface ConsoleFetch { + status: number; + body: JsonRecord; +} + +async function fetchConsoleJson(path: string, cookie: string): Promise { + // #6911: space concurrent upstream quota fetches (mirrors qwenTokenPlanQuotaFetcher). + await throttleQuotaFetch(); + const response = await fetch(`${MIMO_CONSOLE_BASE_URL}${path}`, { + method: "GET", + headers: { + Accept: "application/json", + Cookie: cookie, + }, + signal: AbortSignal.timeout(CONSOLE_FETCH_TIMEOUT_MS), + }); + let body: JsonRecord = {}; + try { + body = toRecord(await response.json()); + } catch { + // Non-JSON body (SSO login redirect page) — treated as a rejected session. + } + return { status: response.status, body }; +} + +/** 401/403, `code !== 0`, an embedded SSO `loginUrl`, or a non-JSON body ⇒ session rejected. */ +function isSessionRejected(status: number, body: JsonRecord): boolean { + if (status === 401 || status === 403) return true; + if (Object.keys(body).length === 0) return true; + const code = body["code"]; + if (code !== undefined && code !== 0 && code !== "0") return true; + const data = toRecord(body["data"]); + return typeof body["loginUrl"] === "string" || typeof data["loginUrl"] === "string"; +} + +// ─── Console payload parsers ──────────────────────────────────────────────── + +/** `tokenPlan/usage` → the `month_total_token` item (used/limit in Credits). */ +export function parseMonthUsage(body: JsonRecord): { used: number; total: number } | null { + const monthUsage = toRecord(toRecord(body["data"])["monthUsage"]); + const items = Array.isArray(monthUsage["items"]) ? monthUsage["items"] : []; + for (const raw of items) { + const item = toRecord(raw); + if (item["name"] !== "month_total_token") continue; + const used = toNumber(item["used"], Number.NaN); + const total = toNumber(item["limit"], Number.NaN); + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return null; + return { used: Math.max(0, used), total }; + } + return null; +} + +/** `tokenPlan/detail` → display plan name + the real window end. */ +export function parsePlanDetail(body: JsonRecord): { + planName: string | null; + resetAt: string | null; +} { + const data = toRecord(body["data"]); + const planName = typeof data["planName"] === "string" ? data["planName"].trim() : ""; + return { planName: planName || null, resetAt: parseConsoleTime(data["currentPeriodEnd"]) }; +} + +function livePlanLabel(planName: string | null): string { + if (!planName) return PLAN_LABEL_LIVE; + return `${PLAN_LABEL_LIVE} · ${toTitleCase(planName)}`; +} + +// ─── Live fetch ───────────────────────────────────────────────────────────── + +type LiveQuotaResult = { plan: string; quotas: Record } | { message: string }; + +async function fetchLiveTokenPlanUsage(cookie: string): Promise { + const [usageFetch, detailFetch] = await Promise.all([ + fetchConsoleJson("/tokenPlan/usage", cookie), + fetchConsoleJson("/tokenPlan/detail", cookie), + ]); + + if (isSessionRejected(usageFetch.status, usageFetch.body)) { + return { message: CONSOLE_COOKIE_HINT }; + } + + const month = parseMonthUsage(usageFetch.body); + if (!month) { + return { + message: `${PLAN_LABEL_LIVE}: console usage payload unrecognized — cannot map the Token Plan window.`, + }; + } + + // The detail call only supplies the label + the real window end; when it is + // unavailable the monthly window falls back to the calendar-month reset. + const detail = isSessionRejected(detailFetch.status, detailFetch.body) + ? { planName: null, resetAt: null } + : parsePlanDetail(detailFetch.body); + + return { + plan: livePlanLabel(detail.planName), + quotas: { + monthly: createQuotaFromUsage(month.used, month.total, detail.resetAt ?? nextMonthStartUtc()), + }, + }; +} + +// ─── Entry point ──────────────────────────────────────────────────────────── + +export async function getXiaomiMimoUsage( + connectionId: string, + provider = "xiaomi-mimo", + providerSpecificData?: JsonRecord +) { if (!connectionId) { return { message: "Xiaomi MiMo: connection id unavailable for self-tracked quota." }; } + + let hint = CONSOLE_COOKIE_HINT; + const cookie = getConsoleCookie(providerSpecificData); + if (cookie) { + try { + const live = await fetchLiveTokenPlanUsage(cookie); + if (!("message" in live)) return live; + hint = live.message; + } catch (error) { + hint = `Xiaomi MiMo console usage unavailable: ${(error as Error).message}`; + } + } + + // Fallback: self-tracked monthly counter against the configured budget. try { const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats"); const used = getMonthlyProviderTokensForConnection(provider, connectionId); - const total = XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT; - const now = new Date(); - const resetAt = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1) - ).toISOString(); + const total = resolveMonthlyTokenLimit(providerSpecificData); return { - plan: "Xiaomi MiMo Token Plan (OmniRoute-tracked)", + plan: PLAN_LABEL_TRACKED, quotas: { - monthly: createQuotaFromUsage(used, total, resetAt), + monthly: createQuotaFromUsage(used, total, nextMonthStartUtc()), }, + message: hint, }; } catch (error) { return { message: `Xiaomi MiMo self-tracked usage error: ${(error as Error).message}` }; } } + +export const __testing = { + parseConsoleTime, + parseMonthUsage, + parsePlanDetail, + resolveMonthlyTokenLimit, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index e15f4d0e5079..6ead4847896f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -384,12 +384,7 @@ export default function EditConnectionModal({ glmOrganizationId: existingGlmOrganizationId, glmProjectId: existingGlmProjectId, // Console-session credentials stripped in responses; blank preserves stored values. - ollamaCloudUsageCookie: "", - alibabaConsoleCookie: "", - qwenCloudCookie: "", - qwenCloudSecToken: "", - alibabaConsoleSecToken: "", - volcConsoleCookie: "", + ...EMPTY_QUOTA_SCRAPING_FIELDS, ccCompatibleContext1m: ccRequestDefaults.context1m, ccCompatibleRedactThinking: ccRequestDefaults.redactThinking, ccCompatibleSummarizeThinking: ccRequestDefaults.summarizeThinking, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx index a2772d86770c..0febe0bbbb36 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -9,6 +9,7 @@ import { EMPTY_QUOTA_SCRAPING_FIELDS, QWEN_TOKEN_PLAN_PROVIDERS, VOLCENGINE_PLAN_PROVIDERS, + XIAOMI_MIMO_PROVIDERS, type QuotaScrapingFieldValues, } from "./quotaScrapingFieldValues"; @@ -178,5 +179,34 @@ export default function QuotaScrapingFields({ ); } + if (XIAOMI_MIMO_PROVIDERS.has(provider ?? "")) { + return ( +
+ onChange({ xiaomiMimoConsoleCookie: e.target.value })} + placeholder="api-platform_serviceToken=...; userId=..." + hint={providerText( + t, + "xiaomiMimoConsoleCookieHint", + (editMode ? "Leave blank to keep the stored cookie. " : "") + + "Required for live Token Plan quota — the inference API key cannot read it. " + + "How to get it: open platform.xiaomimimo.com (logged in) on the Token Plan page, " + + "press F12 › Network, reload, click any request to platform.xiaomimimo.com, then " + + "under Request Headers copy the WHOLE Cookie value (it contains " + + "api-platform_serviceToken and userId). It expires with the browser session — " + + "re-paste it when the quota reports an expired session." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + return null; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts index f476d609cb0e..72bf1a23d3f1 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts @@ -18,6 +18,9 @@ export const VOLCENGINE_PLAN_PROVIDERS = new Set([ "volcengine-agent-plan", ]); +/** Providers whose Token Plan quota lives behind the Xiaomi MiMo console (#15753). */ +export const XIAOMI_MIMO_PROVIDERS = new Set(["xiaomi-mimo", "xiaomi-mimo-token-plan"]); + export type QuotaScrapingFieldValues = { ollamaCloudUsageCookie: string; alibabaConsoleCookie: string; @@ -25,6 +28,7 @@ export type QuotaScrapingFieldValues = { qwenCloudCookie: string; qwenCloudSecToken: string; volcConsoleCookie: string; + xiaomiMimoConsoleCookie: string; }; export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { @@ -34,6 +38,7 @@ export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { qwenCloudCookie: "", qwenCloudSecToken: "", volcConsoleCookie: "", + xiaomiMimoConsoleCookie: "", }; export function assignQuotaScrapingProviderData( @@ -61,5 +66,9 @@ export function assignQuotaScrapingProviderData( } } else if (VOLCENGINE_PLAN_PROVIDERS.has(provider ?? "") && values.volcConsoleCookie?.trim()) { target.volcConsoleCookie = values.volcConsoleCookie.trim(); + } else if (XIAOMI_MIMO_PROVIDERS.has(provider ?? "") && values.xiaomiMimoConsoleCookie?.trim()) { + // Optional access: callers may pass a partial form object without the newer + // fields (same contract as the qwen branch above). + target.xiaomiMimoConsoleCookie = values.xiaomiMimoConsoleCookie.trim(); } } diff --git a/src/lib/compliance/providerAudit.ts b/src/lib/compliance/providerAudit.ts index 853d201986ea..4c4b6c960601 100644 --- a/src/lib/compliance/providerAudit.ts +++ b/src/lib/compliance/providerAudit.ts @@ -78,6 +78,7 @@ export function summarizeProviderConnectionForAudit(connection: unknown) { delete sanitizedProviderSpecificData.consoleApiKey; delete sanitizedProviderSpecificData.alibabaConsoleCookie; delete sanitizedProviderSpecificData.alibabaConsoleSecToken; + delete sanitizedProviderSpecificData.xiaomiMimoConsoleCookie; sanitized.providerSpecificData = sanitizedProviderSpecificData; } diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 99aaa00f9d9b..7f67130d10b1 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -373,6 +373,7 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec delete sanitized.qwenCloudSecToken; delete sanitized.alibabaConsoleCookie; delete sanitized.alibabaConsoleSecToken; + delete sanitized.xiaomiMimoConsoleCookie; delete sanitized.runtimeKey; delete sanitized.validationId; delete sanitized.volcConsoleCookie; diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 11a9f0c529eb..c4932a838482 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -23,6 +23,18 @@ const PEAK_HOUR_PROTECTION_MODES = new Set(["block", "avoid"]); const PEAK_HOUR_PROTECTION_DAYS = new Set(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]); export const MAX_PROVIDER_SPECIFIC_TIMEOUT_MS = 86_400_000; // 24h — operator cap, anti-DoS +/** Shared guard for optional positive-number providerSpecificData knobs. */ +function validatePositiveNumberField(value: unknown, key: string, ctx: z.RefinementCtx): void { + if (value === undefined || value === null) return; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a positive number`, + path: [key], + }); + } +} + // #6880 — per-connection prompt-cache capability override, extracted so // validateProviderSpecificData() stays under the complexity gate. function validatePeakHourProtectionBlock(value: unknown, ctx: z.RefinementCtx): void { @@ -438,6 +450,7 @@ export function validateProviderSpecificData( "qwenCloudCookie", "qwenCloudSecToken", "volcConsoleCookie", + "xiaomiMimoConsoleCookie", ] as const) { const value = data[key]; if (value !== undefined && value !== null && typeof value !== "string") { @@ -605,6 +618,9 @@ export function validateProviderSpecificData( } } + // #15753 — self-tracked Xiaomi MiMo monthly budget override (tokens per month). + validatePositiveNumberField(data.monthlyTokenLimit, "monthlyTokenLimit", ctx); + // Per-connection operator timeout tier: a slow model must not monopolize an // executor slot indefinitely. Bounded to 24h (anti-DoS); below 1ms is // meaningless. diff --git a/tests/unit/xiaomi-mimo-cookie-field.test.ts b/tests/unit/xiaomi-mimo-cookie-field.test.ts new file mode 100644 index 000000000000..8c399fd96192 --- /dev/null +++ b/tests/unit/xiaomi-mimo-cookie-field.test.ts @@ -0,0 +1,104 @@ +/** + * xiaomi-mimo-cookie-field.test.ts — the Xiaomi MiMo Token Plan quota fetcher is + * cookie-authenticated (the `tp-`/`mk-` inference key cannot read the console + * Token Plan API), so the connection modal MUST expose a field to paste that + * cookie. Without it the live quota is unconfigurable from the dashboard. + * + * Mirrors the qwen / volcengine console-cookie fields (qwen-token-plan-cookie-field.test.ts, + * volcengine-plan-cookie-field.test.ts) and guards the new + * `providerSpecificData.monthlyTokenLimit` fallback-budget override (#15753). + */ +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("xiaomi-mimo providers persist the MiMo console cookie", () => { + for (const provider of ["xiaomi-mimo", "xiaomi-mimo-token-plan"]) { + const target: Record = {}; + + assignQuotaScrapingProviderData( + provider, + { + ...EMPTY_QUOTA_SCRAPING_FIELDS, + xiaomiMimoConsoleCookie: " api-platform_serviceToken=tok; userId=42 ", + }, + target + ); + + assert.equal( + target.xiaomiMimoConsoleCookie, + "api-platform_serviceToken=tok; userId=42", + `${provider}: cookie must be stored trimmed` + ); + } +}); + +test("a blank cookie does not overwrite the stored one", () => { + const target: Record = {}; + + assignQuotaScrapingProviderData( + "xiaomi-mimo", + { ...EMPTY_QUOTA_SCRAPING_FIELDS, xiaomiMimoConsoleCookie: " " }, + target + ); + + assert.equal( + Object.hasOwn(target, "xiaomiMimoConsoleCookie"), + false, + "blank input must leave the stored cookie untouched" + ); +}); + +test("a form object without the cookie field does not throw", () => { + const target: Record = {}; + const partial = { ...EMPTY_QUOTA_SCRAPING_FIELDS } as Record; + delete partial.xiaomiMimoConsoleCookie; + + assert.doesNotThrow(() => + assignQuotaScrapingProviderData( + "xiaomi-mimo", + partial as unknown as typeof EMPTY_QUOTA_SCRAPING_FIELDS, + target + ) + ); + assert.equal(Object.hasOwn(target, "xiaomiMimoConsoleCookie"), false); +}); + +test("providerSpecificData validation guards the MiMo cookie field", () => { + const ok = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { xiaomiMimoConsoleCookie: "api-platform_serviceToken=tok" }, + }); + assert.equal(ok.success, true, JSON.stringify(ok.error?.issues)); + + const wrongType = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { xiaomiMimoConsoleCookie: 42 }, + }); + assert.equal(wrongType.success, false, "non-string cookie must be rejected"); + + const tooLong = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { xiaomiMimoConsoleCookie: "x".repeat(10_001) }, + }); + assert.equal(tooLong.success, false, "oversized cookie must be rejected"); +}); + +test("providerSpecificData validation guards monthlyTokenLimit as a positive number", () => { + const ok = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { monthlyTokenLimit: 38_000_000_000 }, + }); + assert.equal(ok.success, true, JSON.stringify(ok.error?.issues)); + + for (const bad of [0, -1, "8000000000", Number.NaN]) { + const result = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { monthlyTokenLimit: bad }, + }); + assert.equal(result.success, false, `monthlyTokenLimit=${String(bad)} must be rejected`); + } +}); diff --git a/tests/unit/xiaomi-mimo-live-quota-15753.test.ts b/tests/unit/xiaomi-mimo-live-quota-15753.test.ts new file mode 100644 index 000000000000..af2c3bc00eea --- /dev/null +++ b/tests/unit/xiaomi-mimo-live-quota-15753.test.ts @@ -0,0 +1,232 @@ +/** + * xiaomi-mimo-live-quota-15753.test.ts — TDD regression for #15753. + * + * The xiaomi-mimo quota fetcher used to self-track routed tokens against a + * hardcoded 4.1B (the Lite tier's Credits) forever, so a plan upgrade kept the + * connection `Exhausted` until the calendar-month reset. Fix (two layers): + * + * 1. Live console fetch — with a MiMo console session cookie on the connection + * (`xiaomiMimoConsoleCookie`) the fetcher reads `tokenPlan/usage` + + * `tokenPlan/detail` and reports the real window: used/limit Credits and + * `resetAt` from `currentPeriodEnd` (NOT the 1st of the month). + * 2. Configurable self-tracked fallback — without a working console session + * the monthly budget comes from `providerSpecificData.monthlyTokenLimit`, + * then `XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT`, then the historical 4.1B default, + * and the response carries a hint message instead of failing hard. + */ +import { describe, it, before, after, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// DATA_DIR must be set before any module that opens the DB is imported. +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-xiaomi-live-")); +process.env.DATA_DIR = TMP; +// Disable the shared quota-fetch throttle — tests must not wait on the gate. +process.env.OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS = "0"; + +const core = await import("../../src/lib/db/core.ts"); +const { resetQuotaFetchThrottle } = await import("../../open-sse/services/quotaFetchThrottle.ts"); +const { getUsageForProvider } = await import("../../open-sse/services/usage.ts"); +const X = await import("../../open-sse/services/usage/xiaomi-mimo.ts"); + +const XIAOMI_LIMIT = 4_100_000_000; + +// Real console payloads captured from the MiMo console (issue #15753). +const USAGE_PAYLOAD = { + code: 0, + data: { + monthUsage: { + percent: 0.3935, + items: [ + { name: "month_total_token", used: 14954038035, limit: 38000000000, percent: 0.3935 }, + ], + }, + usage: { + items: [ + { name: "plan_total_token", used: 14954038035, limit: 38000000000, percent: 0.39 }, + { name: "compensation_total_token", used: 0, limit: 0, percent: 0 }, + ], + }, + }, +}; + +const DETAIL_PAYLOAD = { + code: 0, + data: { + planCode: "pro", + planName: "Pro", + currentPeriodEnd: "2026-11-05 23:59:59", + expired: false, + enableAutoRenew: true, + }, +}; + +interface UsageResult { + plan?: string; + quotas?: Record< + string, + { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + resetAt: string | null; + unlimited?: boolean; + } + >; + message?: string; +} + +function insertUsage(connectionId: string, provider: string, tokens: number, timestamp: string) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, connection_id, tokens_input, tokens_output, timestamp) + VALUES (?, ?, ?, ?, ?)` + ).run(provider, connectionId, tokens, 0, timestamp); +} + +const originalFetch = globalThis.fetch; + +describe("xiaomi-mimo live Token Plan quota (#15753)", () => { + before(() => { + core.getDbInstance(); // trigger migrations + resetQuotaFetchThrottle(); + const now = new Date().toISOString(); + insertUsage("conn-live", "xiaomi-mimo", 1_000_000, now); + insertUsage("conn-live", "xiaomi-mimo", 600_000, now); + insertUsage("conn-fallback", "xiaomi-mimo", 1_600_000, now); + }); + + after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + // best-effort temp cleanup + } + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT; + delete process.env.XIAOMI_MIMO_CONSOLE_COOKIE; + }); + + it("console cookie → real plan quota from tokenPlan/usage + tokenPlan/detail", async () => { + let sentCookie = ""; + globalThis.fetch = (async (url: unknown, init?: { headers?: Record }) => { + const u = String(url); + if (u.endsWith("/api/v1/tokenPlan/usage")) { + sentCookie = init?.headers?.Cookie ?? ""; + return Response.json(USAGE_PAYLOAD); + } + if (u.endsWith("/api/v1/tokenPlan/detail")) return Response.json(DETAIL_PAYLOAD); + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const r = (await X.getXiaomiMimoUsage("conn-live", "xiaomi-mimo", { + xiaomiMimoConsoleCookie: " api-platform_serviceToken=tok; userId=42 ", + })) as UsageResult; + + assert.ok(r.quotas, `expected quotas, got message: ${r.message}`); + assert.equal(r.plan, "Xiaomi MiMo Token Plan · Pro"); + assert.equal(sentCookie, "api-platform_serviceToken=tok; userId=42", "cookie is forwarded"); + const m = r.quotas.monthly; + assert.equal(m.used, 14954038035); + assert.equal(m.total, 38000000000); + assert.equal(m.remaining, 23045961965); + assert.ok( + Math.abs((m.remainingPercentage ?? 0) - 60.6) < 0.2, + `remainingPercentage ~60.6, got ${m.remainingPercentage}` + ); + assert.equal(m.resetAt, "2026-11-05T23:59:59.000Z", "resetAt comes from currentPeriodEnd"); + assert.equal(m.unlimited, false); + }); + + it("getUsageForProvider dispatches the console cookie through to the live fetch", async () => { + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.endsWith("/api/v1/tokenPlan/usage")) return Response.json(USAGE_PAYLOAD); + if (u.endsWith("/api/v1/tokenPlan/detail")) return Response.json(DETAIL_PAYLOAD); + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const r = (await getUsageForProvider({ + id: "conn-live", + provider: "xiaomi-mimo", + providerSpecificData: { xiaomiMimoConsoleCookie: "api-platform_serviceToken=tok" }, + })) as UsageResult; + + assert.ok(r.quotas, `expected quotas, got message: ${r.message}`); + assert.equal(r.quotas.monthly.total, 38000000000); + }); + + it("expired console session (401 + loginUrl) → hint message + self-tracked fallback", async () => { + globalThis.fetch = (async () => + Response.json( + { code: 401, loginUrl: "https://account.xiaomi.com/pass/serviceLogin" }, + { status: 401 } + )) as typeof fetch; + + const r = (await X.getXiaomiMimoUsage("conn-fallback", "xiaomi-mimo", { + xiaomiMimoConsoleCookie: "api-platform_serviceToken=expired", + })) as UsageResult; + + assert.match(r.message ?? "", /console cookie/i, "hint must point at the cookie field"); + assert.ok(r.quotas, "self-tracked numbers stay usable"); + assert.equal(r.plan, "Xiaomi MiMo Token Plan (OmniRoute-tracked)"); + assert.equal(r.quotas.monthly.total, XIAOMI_LIMIT); + assert.equal(r.quotas.monthly.used, 1_600_000); + }); + + it("missing cookie → hint message + self-tracked fallback on the 4.1B default", async () => { + const r = (await X.getXiaomiMimoUsage("conn-fallback", "xiaomi-mimo")) as UsageResult; + + assert.match(r.message ?? "", /console cookie/i, "hint must point at the cookie field"); + assert.ok(r.quotas, "self-tracked numbers stay usable"); + assert.equal(r.quotas.monthly.total, XIAOMI_LIMIT, "default limit = 4.1B Lite Credits"); + }); + + it("providerSpecificData.monthlyTokenLimit overrides the fallback budget", async () => { + const r = (await X.getXiaomiMimoUsage("conn-fallback", "xiaomi-mimo", { + monthlyTokenLimit: 8_000_000_000, + })) as UsageResult; + + assert.ok(r.quotas, `expected quotas, got message: ${r.message}`); + assert.equal(r.quotas.monthly.total, 8_000_000_000); + }); + + it("XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT overrides the default (and loses to the connection field)", async () => { + process.env.XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT = "11000000000"; + const envResult = (await X.getXiaomiMimoUsage("conn-fallback", "xiaomi-mimo")) as UsageResult; + assert.ok(envResult.quotas, `expected quotas, got message: ${envResult.message}`); + assert.equal(envResult.quotas.monthly.total, 11_000_000_000, "Standard tier = 11B Credits"); + + const psdResult = (await X.getXiaomiMimoUsage("conn-fallback", "xiaomi-mimo", { + monthlyTokenLimit: 38_000_000_000, + })) as UsageResult; + assert.ok(psdResult.quotas); + assert.equal( + psdResult.quotas.monthly.total, + 38_000_000_000, + "the connection field wins over the env var" + ); + }); + + it("XIAOMI_MIMO_CONSOLE_COOKIE is accepted as a global fallback", async () => { + process.env.XIAOMI_MIMO_CONSOLE_COOKIE = "api-platform_serviceToken=env-tok; userId=7"; + globalThis.fetch = (async (url: unknown) => { + const u = String(url); + if (u.endsWith("/api/v1/tokenPlan/usage")) return Response.json(USAGE_PAYLOAD); + if (u.endsWith("/api/v1/tokenPlan/detail")) return Response.json(DETAIL_PAYLOAD); + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const r = (await X.getXiaomiMimoUsage("conn-live", "xiaomi-mimo")) as UsageResult; + assert.ok(r.quotas, `expected quotas, got message: ${r.message}`); + assert.equal(r.quotas.monthly.total, 38000000000, "live quota, not the fallback budget"); + }); +});