mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(usage): MiniMax + MiniMax-CN quota tracking in provider limits dashboard (#1516)
Integrated into release/v3.7.0
This commit is contained in:
committed by
GitHub
parent
16b0499192
commit
331e1b7d61
@@ -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<string, unknown>;
|
||||
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<string, UsageQuota> = {};
|
||||
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<string, unknown>) {
|
||||
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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1226,6 +1226,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"kimi-coding",
|
||||
"glm",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user