Skip to content

Commit 18b4575

Browse files
committed
feat(usage): add minimax usage snapshot
1 parent 40fb59e commit 18b4575

8 files changed

Lines changed: 316 additions & 1 deletion

docs/concepts/usage-tracking.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ read_when:
2323
- **Gemini CLI**: OAuth tokens in auth profiles.
2424
- **Antigravity**: OAuth tokens in auth profiles.
2525
- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present).
26+
- **MiniMax**: API key (coding plan key); uses the 5‑hour coding plan window.
2627
- **z.ai**: API key via env/config/auth store.
2728

2829
Usage is hidden if no matching OAuth/API credentials exist.

src/infra/provider-usage.auth.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ function resolveZaiApiKey(): string | undefined {
7474
}
7575
}
7676

77+
function resolveMinimaxApiKey(): string | undefined {
78+
const envDirect =
79+
process.env.MINIMAX_CODE_PLAN_KEY?.trim() ||
80+
process.env.MINIMAX_API_KEY?.trim();
81+
if (envDirect) return envDirect;
82+
83+
const envResolved = resolveEnvApiKey("minimax");
84+
if (envResolved?.apiKey) return envResolved.apiKey;
85+
86+
const cfg = loadConfig();
87+
const key = getCustomProviderApiKey(cfg, "minimax");
88+
if (key) return key;
89+
90+
const store = ensureAuthProfileStore();
91+
const apiProfile = listProfilesForProvider(store, "minimax").find((id) => {
92+
const cred = store.profiles[id];
93+
return cred?.type === "api_key" || cred?.type === "token";
94+
});
95+
if (!apiProfile) return undefined;
96+
const cred = store.profiles[apiProfile];
97+
if (cred?.type === "api_key" && cred.key?.trim()) {
98+
return cred.key.trim();
99+
}
100+
if (cred?.type === "token" && cred.token?.trim()) {
101+
return cred.token.trim();
102+
}
103+
return undefined;
104+
}
105+
77106
async function resolveOAuthToken(params: {
78107
provider: UsageProviderId;
79108
agentDir?: string;
@@ -182,6 +211,11 @@ export async function resolveProviderAuths(params: {
182211
if (apiKey) auths.push({ provider, token: apiKey });
183212
continue;
184213
}
214+
if (provider === "minimax") {
215+
const apiKey = resolveMinimaxApiKey();
216+
if (apiKey) auths.push({ provider, token: apiKey });
217+
continue;
218+
}
185219

186220
if (!oauthProviders.includes(provider)) continue;
187221
const auth = await resolveOAuthToken({
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { fetchJson } from "./provider-usage.fetch.shared.js";
2+
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
3+
import type {
4+
ProviderUsageSnapshot,
5+
UsageWindow,
6+
} from "./provider-usage.types.js";
7+
8+
type MinimaxBaseResp = {
9+
status_code?: number;
10+
status_msg?: string;
11+
};
12+
13+
type MinimaxUsageResponse = {
14+
base_resp?: MinimaxBaseResp;
15+
data?: Record<string, unknown>;
16+
[key: string]: unknown;
17+
};
18+
19+
const RESET_KEYS = [
20+
"reset_at",
21+
"resetAt",
22+
"reset_time",
23+
"resetTime",
24+
"expires_at",
25+
"expiresAt",
26+
"expire_at",
27+
"expireAt",
28+
"end_time",
29+
"endTime",
30+
"window_end",
31+
"windowEnd",
32+
] as const;
33+
34+
const PERCENT_KEYS = [
35+
"used_percent",
36+
"usedPercent",
37+
"usage_percent",
38+
"usagePercent",
39+
"used_rate",
40+
"usage_rate",
41+
"used_ratio",
42+
"usage_ratio",
43+
"usedRatio",
44+
"usageRatio",
45+
] as const;
46+
47+
const USED_KEYS = [
48+
"used",
49+
"usage",
50+
"used_amount",
51+
"usedAmount",
52+
"used_tokens",
53+
"usedTokens",
54+
"used_quota",
55+
"usedQuota",
56+
"used_times",
57+
"usedTimes",
58+
"consumed",
59+
] as const;
60+
61+
const TOTAL_KEYS = [
62+
"total",
63+
"total_amount",
64+
"totalAmount",
65+
"total_tokens",
66+
"totalTokens",
67+
"total_quota",
68+
"totalQuota",
69+
"total_times",
70+
"totalTimes",
71+
"limit",
72+
"quota",
73+
"quota_limit",
74+
"quotaLimit",
75+
"max",
76+
] as const;
77+
78+
const REMAINING_KEYS = [
79+
"remain",
80+
"remaining",
81+
"remain_amount",
82+
"remainingAmount",
83+
"remaining_amount",
84+
"remain_tokens",
85+
"remainingTokens",
86+
"remaining_tokens",
87+
"remain_quota",
88+
"remainingQuota",
89+
"remaining_quota",
90+
"remain_times",
91+
"remainingTimes",
92+
"remaining_times",
93+
"left",
94+
] as const;
95+
96+
const PLAN_KEYS = ["plan", "plan_name", "planName", "product", "tier"] as const;
97+
98+
const WINDOW_HOUR_KEYS = [
99+
"window_hours",
100+
"windowHours",
101+
"duration_hours",
102+
"durationHours",
103+
"hours",
104+
] as const;
105+
106+
const WINDOW_MINUTE_KEYS = [
107+
"window_minutes",
108+
"windowMinutes",
109+
"duration_minutes",
110+
"durationMinutes",
111+
"minutes",
112+
] as const;
113+
114+
function isRecord(value: unknown): value is Record<string, unknown> {
115+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
116+
}
117+
118+
function pickNumber(
119+
record: Record<string, unknown>,
120+
keys: readonly string[],
121+
): number | undefined {
122+
for (const key of keys) {
123+
const value = record[key];
124+
if (typeof value === "number" && Number.isFinite(value)) return value;
125+
if (typeof value === "string") {
126+
const parsed = Number.parseFloat(value);
127+
if (Number.isFinite(parsed)) return parsed;
128+
}
129+
}
130+
return undefined;
131+
}
132+
133+
function pickString(
134+
record: Record<string, unknown>,
135+
keys: readonly string[],
136+
): string | undefined {
137+
for (const key of keys) {
138+
const value = record[key];
139+
if (typeof value === "string" && value.trim()) return value.trim();
140+
}
141+
return undefined;
142+
}
143+
144+
function parseEpoch(value: unknown): number | undefined {
145+
if (typeof value === "number" && Number.isFinite(value)) {
146+
if (value < 1e12) return Math.floor(value * 1000);
147+
return Math.floor(value);
148+
}
149+
if (typeof value === "string" && value.trim()) {
150+
const parsed = Date.parse(value);
151+
if (Number.isFinite(parsed)) return parsed;
152+
}
153+
return undefined;
154+
}
155+
156+
function deriveWindowLabel(payload: Record<string, unknown>): string {
157+
const hours = pickNumber(payload, WINDOW_HOUR_KEYS);
158+
if (hours && Number.isFinite(hours)) return `${hours}h`;
159+
const minutes = pickNumber(payload, WINDOW_MINUTE_KEYS);
160+
if (minutes && Number.isFinite(minutes)) return `${minutes}m`;
161+
return "5h";
162+
}
163+
164+
function deriveUsedPercent(payload: Record<string, unknown>): number | null {
165+
const percentRaw = pickNumber(payload, PERCENT_KEYS);
166+
if (percentRaw !== undefined) {
167+
const normalized = percentRaw <= 1 ? percentRaw * 100 : percentRaw;
168+
return clampPercent(normalized);
169+
}
170+
171+
const total = pickNumber(payload, TOTAL_KEYS);
172+
if (!total || total <= 0) return null;
173+
let used = pickNumber(payload, USED_KEYS);
174+
if (used === undefined) {
175+
const remaining = pickNumber(payload, REMAINING_KEYS);
176+
if (remaining !== undefined) used = total - remaining;
177+
}
178+
if (used === undefined || !Number.isFinite(used)) return null;
179+
return clampPercent((used / total) * 100);
180+
}
181+
182+
export async function fetchMinimaxUsage(
183+
apiKey: string,
184+
timeoutMs: number,
185+
fetchFn: typeof fetch,
186+
): Promise<ProviderUsageSnapshot> {
187+
const res = await fetchJson(
188+
"https://api.minimax.io/v1/coding_plan/remains",
189+
{
190+
method: "GET",
191+
headers: {
192+
Authorization: `Bearer ${apiKey}`,
193+
"Content-Type": "application/json",
194+
"MM-API-Source": "Clawdbot",
195+
},
196+
},
197+
timeoutMs,
198+
fetchFn,
199+
);
200+
201+
if (!res.ok) {
202+
return {
203+
provider: "minimax",
204+
displayName: PROVIDER_LABELS.minimax,
205+
windows: [],
206+
error: `HTTP ${res.status}`,
207+
};
208+
}
209+
210+
const data = (await res.json().catch(() => null)) as MinimaxUsageResponse;
211+
if (!isRecord(data)) {
212+
return {
213+
provider: "minimax",
214+
displayName: PROVIDER_LABELS.minimax,
215+
windows: [],
216+
error: "Invalid JSON",
217+
};
218+
}
219+
220+
const baseResp = isRecord(data.base_resp)
221+
? (data.base_resp as MinimaxBaseResp)
222+
: undefined;
223+
if (baseResp && baseResp.status_code && baseResp.status_code !== 0) {
224+
return {
225+
provider: "minimax",
226+
displayName: PROVIDER_LABELS.minimax,
227+
windows: [],
228+
error: baseResp.status_msg?.trim() || "API error",
229+
};
230+
}
231+
232+
const payload = isRecord(data.data) ? data.data : data;
233+
const usedPercent = deriveUsedPercent(payload);
234+
if (usedPercent === null) {
235+
return {
236+
provider: "minimax",
237+
displayName: PROVIDER_LABELS.minimax,
238+
windows: [],
239+
error: "Unsupported response shape",
240+
};
241+
}
242+
243+
const resetAt = parseEpoch(pickString(payload, RESET_KEYS)) ??
244+
parseEpoch(pickNumber(payload, RESET_KEYS));
245+
const windows: UsageWindow[] = [
246+
{
247+
label: deriveWindowLabel(payload),
248+
usedPercent,
249+
resetAt,
250+
},
251+
];
252+
253+
return {
254+
provider: "minimax",
255+
displayName: PROVIDER_LABELS.minimax,
256+
windows,
257+
plan: pickString(payload, PLAN_KEYS),
258+
};
259+
}

src/infra/provider-usage.fetch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
22
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
33
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
44
export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js";
5+
export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js";
56
export { fetchZaiUsage } from "./provider-usage.fetch.zai.js";

src/infra/provider-usage.load.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
fetchCodexUsage,
88
fetchCopilotUsage,
99
fetchGeminiUsage,
10+
fetchMinimaxUsage,
1011
fetchZaiUsage,
1112
} from "./provider-usage.fetch.js";
1213
import {
@@ -70,6 +71,8 @@ export async function loadProviderUsageSummary(
7071
timeoutMs,
7172
fetchFn,
7273
);
74+
case "minimax":
75+
return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn);
7376
case "zai":
7477
return await fetchZaiUsage(auth.token, timeoutMs, fetchFn);
7578
default:

src/infra/provider-usage.shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
88
"github-copilot": "Copilot",
99
"google-gemini-cli": "Gemini",
1010
"google-antigravity": "Antigravity",
11+
minimax: "MiniMax",
1112
"openai-codex": "Codex",
1213
zai: "z.ai",
1314
};
@@ -17,6 +18,7 @@ export const usageProviders: UsageProviderId[] = [
1718
"github-copilot",
1819
"google-gemini-cli",
1920
"google-antigravity",
21+
"minimax",
2022
"openai-codex",
2123
"zai",
2224
];

src/infra/provider-usage.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@ describe("provider usage loading", () => {
114114
},
115115
});
116116
}
117+
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
118+
return makeResponse(200, {
119+
base_resp: { status_code: 0, status_msg: "ok" },
120+
data: {
121+
total: 200,
122+
remain: 50,
123+
reset_at: "2026-01-07T05:00:00Z",
124+
plan_name: "Coding Plan",
125+
},
126+
});
127+
}
117128
return makeResponse(404, "not found");
118129
},
119130
);
@@ -122,15 +133,18 @@ describe("provider usage loading", () => {
122133
now: Date.UTC(2026, 0, 7, 0, 0, 0),
123134
auth: [
124135
{ provider: "anthropic", token: "token-1" },
136+
{ provider: "minimax", token: "token-1b" },
125137
{ provider: "zai", token: "token-2" },
126138
],
127139
fetch: mockFetch,
128140
});
129141

130-
expect(summary.providers).toHaveLength(2);
142+
expect(summary.providers).toHaveLength(3);
131143
const claude = summary.providers.find((p) => p.provider === "anthropic");
144+
const minimax = summary.providers.find((p) => p.provider === "minimax");
132145
const zai = summary.providers.find((p) => p.provider === "zai");
133146
expect(claude?.windows[0]?.label).toBe("5h");
147+
expect(minimax?.windows[0]?.usedPercent).toBe(75);
134148
expect(zai?.plan).toBe("Pro");
135149
expect(mockFetch).toHaveBeenCalled();
136150
});

src/infra/provider-usage.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ export type UsageProviderId =
2222
| "github-copilot"
2323
| "google-gemini-cli"
2424
| "google-antigravity"
25+
| "minimax"
2526
| "openai-codex"
2627
| "zai";

0 commit comments

Comments
 (0)