diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 94a51767b6..2b4a350ba7 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -71,6 +71,21 @@ const CURSOR_USAGE_CONFIG = { clientVersion: CURSOR_REGISTRY_VERSION, }; +const MINIMAX_USAGE_CONFIG = { + minimax: { + usageUrls: [ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ], + }, + "minimax-cn": { + usageUrls: [ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + ], + }, +} as const; + type JsonRecord = Record; type UsageQuota = { used: number; @@ -126,6 +141,257 @@ function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota return quota.total > 0 || quota.remainingPercentage !== undefined; } +function createQuotaFromUsage( + usedValue: unknown, + totalValue: unknown, + resetValue: unknown +): UsageQuota { + const total = Math.max(0, toNumber(totalValue, 0)); + const used = total > 0 ? Math.min(Math.max(0, toNumber(usedValue, 0)), total) : 0; + const remaining = total > 0 ? Math.max(total - used, 0) : 0; + + return { + used, + total, + remaining, + remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0, + resetAt: parseResetTime(resetValue), + unlimited: false, + }; +} + +function getMiniMaxQuotaResetAt( + model: JsonRecord, + capturedAtMs: number, + remainsTimeSnakeKey: string, + remainsTimeCamelKey: string, + endTimeSnakeKey: string, + endTimeCamelKey: string +): string | null { + const remainsMs = toNumber(getFieldValue(model, remainsTimeSnakeKey, remainsTimeCamelKey), 0); + if (remainsMs > 0) { + return new Date(capturedAtMs + remainsMs).toISOString(); + } + + return parseResetTime(getFieldValue(model, endTimeSnakeKey, endTimeCamelKey)); +} + +function isMiniMaxTextQuotaModel(modelName: string): boolean { + const normalized = modelName.trim().toLowerCase(); + return normalized.startsWith("minimax-m") || normalized.startsWith("coding-plan"); +} + +function getMiniMaxSessionTotal(model: JsonRecord): number { + return Math.max( + 0, + toNumber(getFieldValue(model, "current_interval_total_count", "currentIntervalTotalCount"), 0) + ); +} + +function getMiniMaxWeeklyTotal(model: JsonRecord): number { + return Math.max( + 0, + toNumber(getFieldValue(model, "current_weekly_total_count", "currentWeeklyTotalCount"), 0) + ); +} + +function pickMiniMaxRepresentativeModel( + models: JsonRecord[], + getTotal: (model: JsonRecord) => number +): JsonRecord | null { + const withQuota = models.filter((model) => getTotal(model) > 0); + const pool = withQuota.length > 0 ? withQuota : models; + if (pool.length === 0) return null; + + return pool.reduce((best, current) => (getTotal(current) > getTotal(best) ? current : best)); +} + +function getMiniMaxAuthErrorMessage(message: string): string { + const normalized = message.toLowerCase(); + if ( + normalized.includes("token plan") || + normalized.includes("coding plan") || + normalized.includes("active period") || + normalized.includes("invalid api key") || + normalized.includes("invalid key") || + normalized.includes("subscription") + ) { + return "MiniMax Token Plan API key invalid or inactive. Use an active Token Plan key."; + } + + return "MiniMax access denied. Confirm the key is an active Token Plan API key."; +} + +function getMiniMaxErrorSummary(status: number, message: string): string { + const compact = message.replace(/\s+/g, " ").trim(); + if (!compact) { + return `MiniMax usage endpoint error (${status}).`; + } + if (compact.length <= 160) { + return `MiniMax usage endpoint error (${status}): ${compact}`; + } + return `MiniMax usage endpoint error (${status}): ${compact.slice(0, 157)}...`; +} + +async function getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn") { + if (!apiKey) { + return { message: "MiniMax API key not available. Add a Token Plan API key." }; + } + + const usageUrls = MINIMAX_USAGE_CONFIG[provider].usageUrls; + let lastErrorMessage = ""; + + for (let index = 0; index < usageUrls.length; index += 1) { + const usageUrl = usageUrls[index]; + const canFallback = index < usageUrls.length - 1; + + try { + const response = await fetch(usageUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + }); + + const rawText = await response.text(); + let payload: JsonRecord = {}; + if (rawText) { + try { + payload = toRecord(JSON.parse(rawText)); + } catch { + payload = {}; + } + } + + const baseResp = toRecord(getFieldValue(payload, "base_resp", "baseResp")); + const apiStatusCode = toNumber(getFieldValue(baseResp, "status_code", "statusCode"), 0); + const apiStatusMessage = String( + getFieldValue(baseResp, "status_msg", "statusMsg") ?? "" + ).trim(); + const combinedMessage = `${apiStatusMessage} ${rawText}`.trim(); + const authLikeMessage = + /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i; + + if ( + response.status === 401 || + response.status === 403 || + apiStatusCode === 1004 || + authLikeMessage.test(combinedMessage) + ) { + return { message: getMiniMaxAuthErrorMessage(combinedMessage) }; + } + + if (!response.ok) { + lastErrorMessage = getMiniMaxErrorSummary(response.status, combinedMessage); + if ( + (response.status === 404 || response.status === 405 || response.status >= 500) && + canFallback + ) { + continue; + } + return { message: `MiniMax connected. ${lastErrorMessage}` }; + } + + if (rawText && Object.keys(payload).length === 0) { + return { message: "MiniMax connected. Unable to parse usage response." }; + } + + if (apiStatusCode !== 0) { + if (apiStatusMessage) { + return { message: `MiniMax connected. ${apiStatusMessage}` }; + } + return { message: "MiniMax connected. Upstream quota API returned an error." }; + } + + const capturedAtMs = Date.now(); + const modelRemains = getFieldValue(payload, "model_remains", "modelRemains"); + const allModels = Array.isArray(modelRemains) + ? modelRemains.map((item) => toRecord(item)) + : []; + const textModels = allModels.filter((model) => { + const modelName = String(getFieldValue(model, "model_name", "modelName") ?? ""); + return isMiniMaxTextQuotaModel(modelName); + }); + + if (textModels.length === 0) { + return { message: "MiniMax connected. No text quota data was returned." }; + } + + const quotas: Record = {}; + const sessionModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxSessionTotal); + if (sessionModel) { + const total = getMiniMaxSessionTotal(sessionModel); + const remain = Math.max( + 0, + toNumber( + getFieldValue( + sessionModel, + "current_interval_usage_count", + "currentIntervalUsageCount" + ), + 0 + ) + ); + quotas["session (5h)"] = createQuotaFromUsage( + Math.max(total - remain, 0), + total, + getMiniMaxQuotaResetAt( + sessionModel, + capturedAtMs, + "remains_time", + "remainsTime", + "end_time", + "endTime" + ) + ); + } + + const weeklyModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxWeeklyTotal); + if (weeklyModel && getMiniMaxWeeklyTotal(weeklyModel) > 0) { + const total = getMiniMaxWeeklyTotal(weeklyModel); + const remain = Math.max( + 0, + toNumber( + getFieldValue(weeklyModel, "current_weekly_usage_count", "currentWeeklyUsageCount"), + 0 + ) + ); + quotas["weekly (7d)"] = createQuotaFromUsage( + Math.max(total - remain, 0), + total, + getMiniMaxQuotaResetAt( + weeklyModel, + capturedAtMs, + "weekly_remains_time", + "weeklyRemainsTime", + "weekly_end_time", + "weeklyEndTime" + ) + ); + } + + if (Object.keys(quotas).length === 0) { + return { message: "MiniMax connected. Unable to extract text quota usage." }; + } + + return { quotas }; + } catch (error) { + lastErrorMessage = (error as Error).message; + if (!canFallback) { + break; + } + } + } + + return { + message: lastErrorMessage + ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` + : "MiniMax connected. Unable to fetch usage.", + }; +} + async function getGlmUsage(apiKey: string, providerSpecificData?: Record) { const quotaUrl = getGlmQuotaUrl(providerSpecificData); @@ -240,6 +506,9 @@ export async function getUsageForProvider(connection) { case "glm": case "glmt": return await getGlmUsage(apiKey, providerSpecificData); + case "minimax": + case "minimax-cn": + return await getMiniMaxUsage(apiKey, provider); case "cursor": return await getCursorUsage(accessToken); case "bailian-coding-plan": diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 5a46679c0b..68ed4268bf 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -37,6 +37,8 @@ const PROVIDER_CONFIG = { glm: { label: "GLM (Z.AI)", color: "#4A90D9" }, glmt: { label: "GLM Thinking", color: "#2563EB" }, "kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" }, + minimax: { label: "MiniMax", color: "#7C3AED" }, + "minimax-cn": { label: "MiniMax CN", color: "#DC2626" }, }; const TIER_FILTERS = [ @@ -293,6 +295,8 @@ export default function ProviderLimits() { glm: 7, glmt: 8, "kimi-coding": 9, + minimax: 10, + "minimax-cn": 11, }; return [...filteredConnections].sort( (a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9) diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 139cb3c571..a7fe0f76a0 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -43,7 +43,7 @@ interface ProviderConnectionLike { backoffLevel?: number; } -const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt"]); +const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt", "minimax", "minimax-cn"]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 546086c878..2e9789c5fd 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1226,6 +1226,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "kimi-coding", "glm", "glmt", + "minimax", + "minimax-cn", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index 8a83384974..ae40f8b868 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; const providerLimitUtils = await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx"); +const providerConstants = await import("../../src/shared/constants/providers.ts"); test("provider plan fallbacks normalize to Unknown instead of repeating provider labels", () => { const tier = providerLimitUtils.normalizePlanTier("Claude Code"); @@ -53,3 +54,42 @@ test("quota labels normalize session and weekly windows while preserving readabl assert.equal(providerLimitUtils.formatQuotaLabel("weekly sonnet (7d)"), "Weekly Sonnet"); assert.equal(providerLimitUtils.formatQuotaLabel("code_review"), "Code Review"); }); + +test("MiniMax providers are exposed to the limits dashboard support list", () => { + assert.ok(providerConstants.USAGE_SUPPORTED_PROVIDERS.includes("minimax")); + assert.ok(providerConstants.USAGE_SUPPORTED_PROVIDERS.includes("minimax-cn")); +}); + +test("MiniMax quota payloads use generic provider parsing and stale resets still refill", () => { + const future = new Date(Date.now() + 5 * 60_000).toISOString(); + const past = new Date(Date.now() - 5 * 60_000).toISOString(); + + const parsed = providerLimitUtils.parseQuotaData("minimax", { + quotas: { + "session (5h)": { + used: 400, + total: 1500, + remaining: 1100, + remainingPercentage: 73.3, + resetAt: future, + }, + "weekly (7d)": { + used: 1200, + total: 15000, + remaining: 13800, + remainingPercentage: 92, + resetAt: past, + }, + }, + }); + + assert.equal(parsed.length, 2); + assert.equal(parsed[0].name, "session (5h)"); + assert.equal(parsed[0].used, 400); + assert.equal(parsed[0].total, 1500); + assert.equal(parsed[1].name, "weekly (7d)"); + assert.equal(parsed[1].used, 0); + assert.equal(parsed[1].remainingPercentage, 100); + assert.equal(providerLimitUtils.formatQuotaLabel(parsed[0].name), "Session"); + assert.equal(providerLimitUtils.formatQuotaLabel(parsed[1].name), "Weekly"); +}); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 6fab9408e5..17d0cccbae 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -854,6 +854,88 @@ test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => { ); }); +test("usage service covers MiniMax usage parsing, documented endpoint fallback and auth errors", async () => { + const calls: any[] = []; + const beforeCall = Date.now(); + + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), init }); + + if (String(url) === "https://www.minimax.io/v1/token_plan/remains") { + return new Response("missing", { status: 404 }); + } + + if (String(url) === "https://api.minimax.io/v1/api/openplatform/coding_plan/remains") { + assert.equal((init as any).headers.Authorization, "Bearer minimax-key"); + assert.equal((init as any).headers.Accept, "application/json"); + + return new Response( + JSON.stringify({ + base_resp: { status_code: 0, status_msg: "ok" }, + model_remains: [ + { + model_name: "MiniMax-M2.7", + remains_time: 300_000, + current_interval_total_count: 1500, + current_interval_usage_count: 1100, + current_weekly_total_count: 15000, + current_weekly_usage_count: 13800, + weekly_remains_time: 1_800_000, + }, + { + model_name: "image-01", + remains_time: 86_400_000, + current_interval_total_count: 50, + current_interval_usage_count: 45, + }, + ], + }), + { status: 200 } + ); + } + + if (String(url) === "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains") { + return new Response( + JSON.stringify({ + base_resp: { + status_code: 1004, + status_msg: "token plan api key invalid", + }, + }), + { status: 403 } + ); + } + + throw new Error(`unexpected fetch: ${url}`); + }; + + const usage: any = await usageService.getUsageForProvider({ + provider: "minimax", + apiKey: "minimax-key", + }); + + assert.deepEqual( + calls.map((call) => call.url), + [ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ] + ); + assert.equal(usage.quotas["session (5h)"].used, 400); + assert.equal(usage.quotas["session (5h)"].total, 1500); + assert.equal(usage.quotas["session (5h)"].remaining, 1100); + assert.equal(usage.quotas["weekly (7d)"].used, 1200); + assert.equal(usage.quotas["weekly (7d)"].total, 15000); + assert.equal(usage.quotas["weekly (7d)"].remainingPercentage, 92); + assert.ok(Date.parse(usage.quotas["session (5h)"].resetAt) >= beforeCall + 240_000); + + const invalid: any = await usageService.getUsageForProvider({ + provider: "minimax-cn", + apiKey: "bad-minimax-key", + }); + assert.match(invalid.message, /Token Plan API key/i); +}); + test("usage service parses Cursor team quotas and clamps on-demand ratio", async () => { const calls: any[] = []; globalThis.fetch = async (url, init = {}) => {