项目文件夹

文件
2026-07-13 13:39:12 +08:00

347 行
12 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* usage/minimax.ts — MiniMax (minimax / minimax-cn) usage fetcher + quota helpers.
*
* Extracted from services/usage.ts (god-file decomposition): the full MiniMax family —
* plan-label inference, per-window quota assembly, and the getMiniMaxUsage fetcher that
* probes the coding-plan remains endpoints. Depends only on the sibling scalar/quota
* leaves — no host coupling — so it lives as a co-located provider leaf. usage.ts imports
* getMiniMaxUsage (dispatcher) + getMiniMaxPlanLabel/getMiniMaxSessionTotal (__testing).
* Behavior-preserving move.
*/
import { toRecord, toNumber, getFieldValue, pickFirstNonEmptyString } from "./scalars.ts";
import { type UsageQuota, parseResetTime, createQuotaFromUsage } from "./quota.ts";
type JsonRecord = Record<string, unknown>;
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;
export function inferMiniMaxPlanLabelFromTotals(models: JsonRecord[]): string | null {
const maxSessionTotal = models.reduce(
(maxTotal, model) => Math.max(maxTotal, getMiniMaxSessionTotal(model)),
0
);
if (maxSessionTotal >= 15_000) return "Max";
if (maxSessionTotal >= 4_500) return "Plus";
if (maxSessionTotal >= 1_500) return "Starter";
return null;
}
export function getMiniMaxPlanLabel(payload: JsonRecord, models: JsonRecord[] = []): string {
const raw = pickFirstNonEmptyString(
getFieldValue(payload, "current_subscribe_title", "currentSubscribeTitle"),
getFieldValue(payload, "plan_name", "planName"),
getFieldValue(payload, "plan", "plan"),
getFieldValue(payload, "current_plan_title", "currentPlanTitle"),
getFieldValue(payload, "combo_title", "comboTitle")
);
if (!raw) return inferMiniMaxPlanLabelFromTotals(models) || "Coding Plan";
const cleaned = raw
.replace(/^minimax\s+/i, "")
.replace(/\bcoding\s+plan\b/gi, "")
.replace(/\s{2,}/g, " ")
.trim();
return cleaned || inferMiniMaxPlanLabelFromTotals(models) || "Coding Plan";
}
export 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));
}
export function isMiniMaxTextQuotaModel(modelName: string): boolean {
const normalized = modelName.trim().toLowerCase();
return (
normalized.startsWith("minimax-m") ||
normalized.startsWith("coding-plan") ||
// MiniMax Coding Plan surfaces the text/coding quota under model "general"
// (media buckets like "video"/"image"/"music" are excluded).
normalized === "general"
);
}
export function getMiniMaxSessionTotal(model: JsonRecord): number {
return Math.max(
0,
toNumber(getFieldValue(model, "current_interval_total_count", "currentIntervalTotalCount"), 0)
);
}
export 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));
}
export function createMiniMaxQuotaFromCount(
total: number,
count: number,
resetAt: string | null,
countMeansRemaining: boolean
): UsageQuota {
const used = countMeansRemaining ? Math.max(total - count, 0) : count;
return createQuotaFromUsage(used, total, resetAt);
}
/**
* MiniMax Coding Plan exposes per-window remaining as a 0–100 percent
* (`current_interval_remaining_percent` / `current_weekly_remaining_percent`)
* with zero request counts. Read it defensively (string-encoded numbers ok).
*/
export function getMiniMaxRemainingPercent(
model: JsonRecord,
snakeKey: string,
camelKey: string
): number | null {
const raw = getFieldValue(model, snakeKey, camelKey);
if (raw === null || raw === undefined || raw === "") return null;
const parsed = toNumber(raw, NaN);
return Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : null;
}
/** Build a 0–100 percent-based window quota (used = 100 remaining). */
export function createMiniMaxQuotaFromPercent(
remainingPercent: number,
resetAt: string | null
): UsageQuota {
const clamped = Math.max(0, Math.min(100, remainingPercent));
return createQuotaFromUsage(100 - clamped, 100, resetAt);
}
/**
* Build one MiniMax usage window (session or weekly) from the representative
* model. Token Plan keys report request counts (`*_total_count`); Coding Plan
* keys report zero counts and a `*_remaining_percent` instead — fall back to
* that so the Coding Plan still surfaces a quota. The percent signal is keyed
* off "counts == 0 + percent present", NOT the endpoint URL, because the
* `token_plan/remains` and `coding_plan/remains` endpoints return identical
* Coding-Plan payloads for a Coding Plan key.
*/
function buildMiniMaxWindow(
models: JsonRecord[],
getTotal: (model: JsonRecord) => number,
usageCountKeys: [string, string],
percentKeys: [string, string],
resetKeys: [string, string, string, string],
capturedAtMs: number,
countMeansRemaining: boolean
): UsageQuota | null {
const model = pickMiniMaxRepresentativeModel(models, getTotal);
if (!model) return null;
const resetAt = getMiniMaxQuotaResetAt(model, capturedAtMs, ...resetKeys);
const total = getTotal(model);
if (total > 0) {
const count = Math.max(0, toNumber(getFieldValue(model, ...usageCountKeys), 0));
return createMiniMaxQuotaFromCount(total, count, resetAt, countMeansRemaining);
}
const remainingPercent = getMiniMaxRemainingPercent(model, ...percentKeys);
return remainingPercent !== null
? createMiniMaxQuotaFromPercent(remainingPercent, resetAt)
: null;
}
export 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.";
}
export 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)}...`;
}
export 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 authLikeStatusMessage =
/token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i;
if (
response.status === 401 ||
response.status === 403 ||
apiStatusCode === 1004 ||
authLikeStatusMessage.test(apiStatusMessage)
) {
return { message: getMiniMaxAuthErrorMessage(apiStatusMessage || 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 countMeansRemaining = usageUrl.includes("/coding_plan/remains");
const quotas: Record<string, UsageQuota> = {};
const sessionQuota = buildMiniMaxWindow(
textModels,
getMiniMaxSessionTotal,
["current_interval_usage_count", "currentIntervalUsageCount"],
["current_interval_remaining_percent", "currentIntervalRemainingPercent"],
["remains_time", "remainsTime", "end_time", "endTime"],
capturedAtMs,
countMeansRemaining
);
if (sessionQuota) {
quotas["session (5h)"] = sessionQuota;
}
const weeklyQuota = buildMiniMaxWindow(
textModels,
getMiniMaxWeeklyTotal,
["current_weekly_usage_count", "currentWeeklyUsageCount"],
["current_weekly_remaining_percent", "currentWeeklyRemainingPercent"],
["weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"],
capturedAtMs,
countMeansRemaining
);
if (weeklyQuota) {
quotas["weekly (7d)"] = weeklyQuota;
}
if (Object.keys(quotas).length === 0) {
return { message: "MiniMax connected. Unable to extract text quota usage." };
}
return { plan: getMiniMaxPlanLabel(payload, textModels), 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.",
};
}