feat(usage): add minimax usage snapshot
This commit is contained in:
@@ -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 5‑hour coding plan window.
|
||||
- **z.ai**: API key via env/config/auth store.
|
||||
|
||||
Usage is hidden if no matching OAuth/API credentials exist.
|
||||
|
||||
@@ -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({
|
||||
|
||||
259
src/infra/provider-usage.fetch.minimax.ts
Normal file
259
src/infra/provider-usage.fetch.minimax.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -22,5 +22,6 @@ export type UsageProviderId =
|
||||
| "github-copilot"
|
||||
| "google-gemini-cli"
|
||||
| "google-antigravity"
|
||||
| "minimax"
|
||||
| "openai-codex"
|
||||
| "zai";
|
||||
|
||||
Reference in New Issue
Block a user