Files
openclaw/src/infra/provider-usage.fetch.minimax.ts
2026-04-04 20:32:15 +09:00

470 lines
12 KiB
TypeScript

import { isRecord } from "../utils.js";
import {
buildUsageHttpErrorSnapshot,
fetchJson,
parseFiniteNumber,
} from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type MinimaxBaseResp = {
status_code?: number;
status_msg?: string;
};
type MinimaxUsageResponse = {
base_resp?: MinimaxBaseResp;
data?: Record<string, unknown>;
[key: string]: unknown;
};
const RESET_KEYS = [
"reset_at",
"resetAt",
"reset_time",
"resetTime",
"next_reset_at",
"nextResetAt",
"next_reset_time",
"nextResetTime",
"expires_at",
"expiresAt",
"expire_at",
"expireAt",
"end_time",
"endTime",
"window_end",
"windowEnd",
] as const;
const PERCENT_KEYS = [
"used_percent",
"usedPercent",
"used_rate",
"usage_rate",
"used_ratio",
"usage_ratio",
"usedRatio",
"usageRatio",
] as const;
// MiniMax's usage_percent / usagePercent fields report the remaining quota
// as a percentage, not the consumed quota. Treat them as "remaining percent"
// and invert to get usedPercent. Count-based fromCounts always takes priority.
const REMAINING_PERCENT_KEYS = ["usage_percent", "usagePercent"] as const;
const USED_KEYS = [
"used",
"usage",
"used_amount",
"usedAmount",
"used_tokens",
"usedTokens",
"used_quota",
"usedQuota",
"used_times",
"usedTimes",
"prompt_used",
"promptUsed",
"used_prompt",
"usedPrompt",
"prompts_used",
"promptsUsed",
"consumed",
] as const;
const TOTAL_KEYS = [
"total",
"total_amount",
"totalAmount",
"total_tokens",
"totalTokens",
"total_quota",
"totalQuota",
"total_times",
"totalTimes",
"prompt_total",
"promptTotal",
"total_prompt",
"totalPrompt",
"prompt_limit",
"promptLimit",
"limit_prompt",
"limitPrompt",
"prompts_total",
"promptsTotal",
"total_prompts",
"totalPrompts",
"current_interval_total_count",
"currentIntervalTotalCount",
"current_weekly_total_count",
"currentWeeklyTotalCount",
"limit",
"quota",
"quota_limit",
"quotaLimit",
"max",
] as const;
const REMAINING_KEYS = [
"remain",
"remaining",
"remain_amount",
"remainingAmount",
"remaining_amount",
"remain_tokens",
"remainingTokens",
"remaining_tokens",
"remain_quota",
"remainingQuota",
"remaining_quota",
"remain_times",
"remainingTimes",
"remaining_times",
"prompt_remain",
"promptRemain",
"remain_prompt",
"remainPrompt",
"prompt_remaining",
"promptRemaining",
"remaining_prompt",
"remainingPrompt",
"prompts_remaining",
"promptsRemaining",
"prompt_left",
"promptLeft",
"prompts_left",
"promptsLeft",
"left",
// MiniMax `/coding_plan/remains` misnames these: values are remaining quota, not consumed.
// See https://github.com/MiniMax-AI/MiniMax-M2/issues/99
"current_interval_usage_count",
"currentIntervalUsageCount",
"current_weekly_usage_count",
"currentWeeklyUsageCount",
] as const;
const PLAN_KEYS = ["plan", "plan_name", "planName", "product", "tier"] as const;
const WINDOW_HOUR_KEYS = [
"window_hours",
"windowHours",
"duration_hours",
"durationHours",
"hours",
] as const;
const WINDOW_MINUTE_KEYS = [
"window_minutes",
"windowMinutes",
"duration_minutes",
"durationMinutes",
"minutes",
] as const;
function pickNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
for (const key of keys) {
const parsed = parseFiniteNumber(record[key]);
if (parsed !== undefined) {
return parsed;
}
}
return undefined;
}
function pickString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
}
function parseEpoch(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
if (value < 1e12) {
return Math.floor(value * 1000);
}
return Math.floor(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function hasAny(record: Record<string, unknown>, keys: readonly string[]): boolean {
return keys.some((key) => key in record);
}
function scoreUsageRecord(record: Record<string, unknown>): number {
let score = 0;
if (hasAny(record, PERCENT_KEYS)) {
score += 4;
}
if (hasAny(record, TOTAL_KEYS)) {
score += 3;
}
if (hasAny(record, USED_KEYS) || hasAny(record, REMAINING_KEYS)) {
score += 2;
}
if (hasAny(record, RESET_KEYS)) {
score += 1;
}
if (hasAny(record, PLAN_KEYS)) {
score += 1;
}
return score;
}
function collectUsageCandidates(root: Record<string, unknown>): Record<string, unknown>[] {
const MAX_SCAN_DEPTH = 4;
const MAX_SCAN_NODES = 60;
const queue: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
const seen = new Set<object>();
const candidates: Array<{ record: Record<string, unknown>; score: number; depth: number }> = [];
let scanned = 0;
while (queue.length && scanned < MAX_SCAN_NODES) {
const next = queue.shift() as { value: unknown; depth: number };
scanned += 1;
const { value, depth } = next;
if (isRecord(value)) {
if (seen.has(value)) {
continue;
}
seen.add(value);
const score = scoreUsageRecord(value);
if (score > 0) {
candidates.push({ record: value, score, depth });
}
if (depth < MAX_SCAN_DEPTH) {
for (const nested of Object.values(value)) {
if (isRecord(nested) || Array.isArray(nested)) {
queue.push({ value: nested, depth: depth + 1 });
}
}
}
continue;
}
if (Array.isArray(value) && depth < MAX_SCAN_DEPTH) {
for (const nested of value) {
if (isRecord(nested) || Array.isArray(nested)) {
queue.push({ value: nested, depth: depth + 1 });
}
}
}
}
candidates.sort((a, b) => b.score - a.score || a.depth - b.depth);
return candidates.map((candidate) => candidate.record);
}
function deriveWindowLabelFromTimestamps(record: Record<string, unknown>): string | undefined {
const startTime = parseEpoch(record.start_time ?? record.startTime);
const endTime = parseEpoch(record.end_time ?? record.endTime);
if (startTime !== undefined && endTime !== undefined && endTime > startTime) {
const durationHours = (endTime - startTime) / 3_600_000;
if (durationHours >= 1 && Number.isFinite(durationHours)) {
const rounded = Math.round(durationHours);
return `${rounded}h`;
}
const durationMinutes = Math.round((endTime - startTime) / 60_000);
if (durationMinutes > 0) {
return `${durationMinutes}m`;
}
}
return undefined;
}
function deriveWindowLabel(payload: Record<string, unknown>): string {
const hours = pickNumber(payload, WINDOW_HOUR_KEYS);
if (hours && Number.isFinite(hours)) {
return `${hours}h`;
}
const minutes = pickNumber(payload, WINDOW_MINUTE_KEYS);
if (minutes && Number.isFinite(minutes)) {
return `${minutes}m`;
}
const fromTimestamps = deriveWindowLabelFromTimestamps(payload);
if (fromTimestamps) {
return fromTimestamps;
}
return "5h";
}
function deriveUsedPercent(payload: Record<string, unknown>): number | null {
const total = pickNumber(payload, TOTAL_KEYS);
let used = pickNumber(payload, USED_KEYS);
const remaining = pickNumber(payload, REMAINING_KEYS);
if (used === undefined && remaining !== undefined && total !== undefined) {
used = total - remaining;
}
const fromCounts =
total && total > 0 && used !== undefined && Number.isFinite(used)
? clampPercent((used / total) * 100)
: null;
// Count-derived usage is more stable across provider percent field variations.
if (fromCounts !== null) {
return fromCounts;
}
const percentRaw = pickNumber(payload, PERCENT_KEYS);
if (percentRaw !== undefined) {
return clampPercent(percentRaw <= 1 ? percentRaw * 100 : percentRaw);
}
// usage_percent / usagePercent in MiniMax's API represents remaining quota,
// not consumed quota. Invert to get usedPercent.
const remainingPercentRaw = pickNumber(payload, REMAINING_PERCENT_KEYS);
if (remainingPercentRaw !== undefined) {
const remainingNormalized = clampPercent(
remainingPercentRaw <= 1 ? remainingPercentRaw * 100 : remainingPercentRaw,
);
return clampPercent(100 - remainingNormalized);
}
return null;
}
// Prefer the entry whose model_name matches a chat/text model (e.g. "MiniMax-M*")
// and that has a non-zero current_interval_total_count. Models with total_count === 0
// (speech, video, image) are not relevant to the coding-plan budget.
function pickChatModelRemains(modelRemains: unknown[]): Record<string, unknown> | undefined {
const records = modelRemains.filter(isRecord);
if (records.length === 0) {
return undefined;
}
const chatRecord = records.find((r) => {
const name = typeof r.model_name === "string" ? r.model_name : "";
const total = parseFiniteNumber(r.current_interval_total_count);
return name.toLowerCase().startsWith("minimax-m") && total !== undefined && total > 0;
});
if (chatRecord) {
return chatRecord;
}
return records.find((r) => {
const total = parseFiniteNumber(r.current_interval_total_count);
return total !== undefined && total > 0;
});
}
export async function fetchMinimaxUsage(
apiKey: string,
timeoutMs: number,
fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> {
const res = await fetchJson(
"https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"MM-API-Source": "OpenClaw",
},
},
timeoutMs,
fetchFn,
);
if (!res.ok) {
return buildUsageHttpErrorSnapshot({
provider: "minimax",
status: res.status,
});
}
const data = (await res.json().catch(() => null)) as MinimaxUsageResponse;
if (!isRecord(data)) {
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows: [],
error: "Invalid JSON",
};
}
const baseResp = isRecord(data.base_resp) ? data.base_resp : undefined;
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows: [],
error: baseResp.status_msg?.trim() || "API error",
};
}
const payload = isRecord(data.data) ? data.data : data;
// Handle the model_remains array structure returned by the coding-plan
// endpoint. Pick the chat-model entry so that speech/video/image quotas
// (which often have total_count === 0) don't shadow the relevant budget.
const modelRemains = Array.isArray(payload.model_remains) ? payload.model_remains : null;
const chatRemains = modelRemains ? pickChatModelRemains(modelRemains) : undefined;
const usageSource = chatRemains ?? payload;
const candidates = collectUsageCandidates(usageSource);
let usageRecord: Record<string, unknown> = usageSource;
let usedPercent: number | null = null;
for (const candidate of candidates) {
const candidatePercent = deriveUsedPercent(candidate);
if (candidatePercent !== null) {
usageRecord = candidate;
usedPercent = candidatePercent;
break;
}
}
if (usedPercent === null) {
usedPercent = deriveUsedPercent(usageSource);
}
if (usedPercent === null) {
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows: [],
error: "Unsupported response shape",
};
}
const resetAt =
parseEpoch(pickString(usageRecord, RESET_KEYS)) ??
parseEpoch(pickNumber(usageRecord, RESET_KEYS)) ??
parseEpoch(pickString(payload, RESET_KEYS)) ??
parseEpoch(pickNumber(payload, RESET_KEYS));
const windowLabel = chatRemains ? deriveWindowLabel(chatRemains) : deriveWindowLabel(usageRecord);
const windows: UsageWindow[] = [
{
label: windowLabel,
usedPercent,
resetAt,
},
];
const modelName =
chatRemains && typeof chatRemains.model_name === "string" ? chatRemains.model_name : undefined;
const plan =
pickString(usageRecord, PLAN_KEYS) ??
pickString(payload, PLAN_KEYS) ??
(modelName ? `Coding Plan · ${modelName}` : undefined);
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows,
plan,
};
}