diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 54224f4cd..fd8eba6f4 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -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. diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index b2dd5afb1..e1a9d79f6 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -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({ diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts new file mode 100644 index 000000000..c493213ab --- /dev/null +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -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; + [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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function pickNumber( + record: Record, + 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, + 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 { + 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): 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 { + 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), + }; +} diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index 76d41dfb7..e0bcd60c9 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -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"; diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index ce1f9c8f9..23f4f1de7 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -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: diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index c0872dd43..ecf7bfea7 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -8,6 +8,7 @@ export const PROVIDER_LABELS: Record = { "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", ]; diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 91ef4d675..8de3f61e9 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -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(); }); diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index 0fb18a794..cef446ceb 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -22,5 +22,6 @@ export type UsageProviderId = | "github-copilot" | "google-gemini-cli" | "google-antigravity" + | "minimax" | "openai-codex" | "zai";