feat(usage): add minimax usage snapshot

This commit is contained in:
Peter Steinberger
2026-01-14 09:57:32 +00:00
parent 40fb59e5f7
commit 18b4575e4d
8 changed files with 316 additions and 1 deletions

View File

@@ -23,6 +23,7 @@ read_when:
- **Gemini CLI**: OAuth tokens in auth profiles.
- **Antigravity**: OAuth tokens in auth profiles.
- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present).
- **MiniMax**: API key (coding plan key); uses the 5hour coding plan window.
- **z.ai**: API key via env/config/auth store.
Usage is hidden if no matching OAuth/API credentials exist.

View File

@@ -74,6 +74,35 @@ function resolveZaiApiKey(): string | undefined {
}
}
function resolveMinimaxApiKey(): string | undefined {
const envDirect =
process.env.MINIMAX_CODE_PLAN_KEY?.trim() ||
process.env.MINIMAX_API_KEY?.trim();
if (envDirect) return envDirect;
const envResolved = resolveEnvApiKey("minimax");
if (envResolved?.apiKey) return envResolved.apiKey;
const cfg = loadConfig();
const key = getCustomProviderApiKey(cfg, "minimax");
if (key) return key;
const store = ensureAuthProfileStore();
const apiProfile = listProfilesForProvider(store, "minimax").find((id) => {
const cred = store.profiles[id];
return cred?.type === "api_key" || cred?.type === "token";
});
if (!apiProfile) return undefined;
const cred = store.profiles[apiProfile];
if (cred?.type === "api_key" && cred.key?.trim()) {
return cred.key.trim();
}
if (cred?.type === "token" && cred.token?.trim()) {
return cred.token.trim();
}
return undefined;
}
async function resolveOAuthToken(params: {
provider: UsageProviderId;
agentDir?: string;
@@ -182,6 +211,11 @@ export async function resolveProviderAuths(params: {
if (apiKey) auths.push({ provider, token: apiKey });
continue;
}
if (provider === "minimax") {
const apiKey = resolveMinimaxApiKey();
if (apiKey) auths.push({ provider, token: apiKey });
continue;
}
if (!oauthProviders.includes(provider)) continue;
const auth = await resolveOAuthToken({

View File

@@ -0,0 +1,259 @@
import { fetchJson } 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",
"expires_at",
"expiresAt",
"expire_at",
"expireAt",
"end_time",
"endTime",
"window_end",
"windowEnd",
] as const;
const PERCENT_KEYS = [
"used_percent",
"usedPercent",
"usage_percent",
"usagePercent",
"used_rate",
"usage_rate",
"used_ratio",
"usage_ratio",
"usedRatio",
"usageRatio",
] as const;
const USED_KEYS = [
"used",
"usage",
"used_amount",
"usedAmount",
"used_tokens",
"usedTokens",
"used_quota",
"usedQuota",
"used_times",
"usedTimes",
"consumed",
] as const;
const TOTAL_KEYS = [
"total",
"total_amount",
"totalAmount",
"total_tokens",
"totalTokens",
"total_quota",
"totalQuota",
"total_times",
"totalTimes",
"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",
"left",
] 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 isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function pickNumber(
record: Record<string, unknown>,
keys: readonly string[],
): number | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) 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 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`;
return "5h";
}
function deriveUsedPercent(payload: Record<string, unknown>): number | null {
const percentRaw = pickNumber(payload, PERCENT_KEYS);
if (percentRaw !== undefined) {
const normalized = percentRaw <= 1 ? percentRaw * 100 : percentRaw;
return clampPercent(normalized);
}
const total = pickNumber(payload, TOTAL_KEYS);
if (!total || total <= 0) return null;
let used = pickNumber(payload, USED_KEYS);
if (used === undefined) {
const remaining = pickNumber(payload, REMAINING_KEYS);
if (remaining !== undefined) used = total - remaining;
}
if (used === undefined || !Number.isFinite(used)) return null;
return clampPercent((used / total) * 100);
}
export async function fetchMinimaxUsage(
apiKey: string,
timeoutMs: number,
fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> {
const res = await fetchJson(
"https://api.minimax.io/v1/coding_plan/remains",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"MM-API-Source": "Clawdbot",
},
},
timeoutMs,
fetchFn,
);
if (!res.ok) {
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows: [],
error: `HTTP ${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 as MinimaxBaseResp)
: undefined;
if (baseResp && baseResp.status_code && 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;
const usedPercent = deriveUsedPercent(payload);
if (usedPercent === null) {
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows: [],
error: "Unsupported response shape",
};
}
const resetAt = parseEpoch(pickString(payload, RESET_KEYS)) ??
parseEpoch(pickNumber(payload, RESET_KEYS));
const windows: UsageWindow[] = [
{
label: deriveWindowLabel(payload),
usedPercent,
resetAt,
},
];
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
windows,
plan: pickString(payload, PLAN_KEYS),
};
}

View File

@@ -2,4 +2,5 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js";
export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js";
export { fetchZaiUsage } from "./provider-usage.fetch.zai.js";

View File

@@ -7,6 +7,7 @@ import {
fetchCodexUsage,
fetchCopilotUsage,
fetchGeminiUsage,
fetchMinimaxUsage,
fetchZaiUsage,
} from "./provider-usage.fetch.js";
import {
@@ -70,6 +71,8 @@ export async function loadProviderUsageSummary(
timeoutMs,
fetchFn,
);
case "minimax":
return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn);
case "zai":
return await fetchZaiUsage(auth.token, timeoutMs, fetchFn);
default:

View File

@@ -8,6 +8,7 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
"github-copilot": "Copilot",
"google-gemini-cli": "Gemini",
"google-antigravity": "Antigravity",
minimax: "MiniMax",
"openai-codex": "Codex",
zai: "z.ai",
};
@@ -17,6 +18,7 @@ export const usageProviders: UsageProviderId[] = [
"github-copilot",
"google-gemini-cli",
"google-antigravity",
"minimax",
"openai-codex",
"zai",
];

View File

@@ -114,6 +114,17 @@ describe("provider usage loading", () => {
},
});
}
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
total: 200,
remain: 50,
reset_at: "2026-01-07T05:00:00Z",
plan_name: "Coding Plan",
},
});
}
return makeResponse(404, "not found");
},
);
@@ -122,15 +133,18 @@ describe("provider usage loading", () => {
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [
{ provider: "anthropic", token: "token-1" },
{ provider: "minimax", token: "token-1b" },
{ provider: "zai", token: "token-2" },
],
fetch: mockFetch,
});
expect(summary.providers).toHaveLength(2);
expect(summary.providers).toHaveLength(3);
const claude = summary.providers.find((p) => p.provider === "anthropic");
const minimax = summary.providers.find((p) => p.provider === "minimax");
const zai = summary.providers.find((p) => p.provider === "zai");
expect(claude?.windows[0]?.label).toBe("5h");
expect(minimax?.windows[0]?.usedPercent).toBe(75);
expect(zai?.plan).toBe("Pro");
expect(mockFetch).toHaveBeenCalled();
});

View File

@@ -22,5 +22,6 @@ export type UsageProviderId =
| "github-copilot"
| "google-gemini-cli"
| "google-antigravity"
| "minimax"
| "openai-codex"
| "zai";