Skip to content

Commit e548747

Browse files
committed
feat: use coding plan quota remains
- switch quota calls to the coding plan remains endpoint because the usage page now exposes aggregated resources through that API - render current and weekly remaining quota in a compact single-line layout so terminal users can scan the new response shape quickly - add regression coverage for coding plan remaining percentages and updated quota endpoint paths
1 parent 329dae9 commit e548747

8 files changed

Lines changed: 165 additions & 52 deletions

File tree

src/client/endpoints.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function vlmEndpoint(baseUrl: string): string {
4141
export function quotaEndpoint(baseUrl: string): string {
4242
// Quota endpoint uses api subdomain
4343
const host = baseUrl.includes('minimaxi.com') ? 'https://api.minimaxi.com' : 'https://api.minimax.io';
44-
return `${host}/v1/token_plan/remains`;
44+
return `${host}/v1/api/openplatform/coding_plan/remains`;
4545
}
4646

4747
export function fileUploadEndpoint(baseUrl: string): string {

src/config/detect-region.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { REGIONS, type Region } from "./schema";
22
import { readConfigFile, writeConfigFile } from "./loader";
33

4-
const QUOTA_PATH = "/v1/token_plan/remains";
4+
const QUOTA_PATH = "/v1/api/openplatform/coding_plan/remains";
55

66
function quotaUrl(region: Region): string {
77
return REGIONS[region] + QUOTA_PATH;

src/output/quota-table.ts

Lines changed: 88 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,34 @@ const BG_YELLOW = '\x1b[48;2;202;138;4m';
1616
const BG_RED = '\x1b[48;2;220;38;38m';
1717
const BG_EMPTY = '\x1b[48;2;55;65;81m';
1818

19-
function usageColors(usedPct: number): [string, string] {
20-
if (usedPct < 50) return [FG_GREEN, BG_GREEN];
21-
if (usedPct <= 80) return [FG_YELLOW, BG_YELLOW];
19+
function remainingColors(remainingPct: number): [string, string] {
20+
if (remainingPct >= 50) return [FG_GREEN, BG_GREEN];
21+
if (remainingPct >= 20) return [FG_YELLOW, BG_YELLOW];
2222
return [FG_RED, BG_RED];
2323
}
2424

2525
interface Labels {
2626
dashboard: string;
2727
week: string;
28+
current: string;
2829
weekly: string;
2930
resetsIn: string;
3031
noData: string;
3132
now: string;
3233
}
3334

34-
const LABELS_EN: Labels = { dashboard: 'TokenPlan Quota', week: 'Week', weekly: 'Weekly', resetsIn: 'Resets in', noData: 'No quota data available.', now: 'now' };
35-
const LABELS_CN: Labels = { dashboard: 'TokenPlan 配额面板', week: '周期', weekly: '每周', resetsIn: '重置于', noData: '暂无配额数据', now: '即将' };
35+
const LABELS_EN: Labels = { dashboard: 'TokenPlan Quota', week: 'Week', current: 'Left', weekly: 'Wk left', resetsIn: 'Reset', noData: 'No quota data available.', now: 'now' };
36+
const LABELS_CN: Labels = { dashboard: 'TokenPlan 配额面板', week: '周期', current: '剩余', weekly: '周剩余', resetsIn: '重置', noData: '暂无配额数据', now: '即将' };
37+
38+
const MODEL_NAME_CN: Record<string, string> = {
39+
'general': '通用',
40+
'video': '视频',
41+
};
42+
43+
function displayModelName(name: string, region: string): string {
44+
if (region !== 'cn') return name;
45+
return MODEL_NAME_CN[name] ?? name;
46+
}
3647

3748
function formatDuration(ms: number, nowLabel: string): string {
3849
if (ms <= 0) return nowLabel;
@@ -60,15 +71,47 @@ function displayWidth(s: string): number {
6071
}
6172

6273
const BAR_WIDTH = 16;
74+
const COMPACT_BAR_WIDTH = 10;
6375

64-
function renderBar(usedPct: number, color: boolean): string {
65-
const ratio = Math.max(0, Math.min(100, usedPct)) / 100;
66-
const filled = Math.round(BAR_WIDTH * ratio);
67-
const empty = BAR_WIDTH - filled;
68-
const pctStr = `${usedPct}%`.padStart(4);
69-
if (!color) return `[${'█'.repeat(filled)}${'.'.repeat(empty)}] ${pctStr}`;
70-
const [fg, bg] = usageColors(usedPct);
71-
return `${bg}${' '.repeat(filled)}${R}${BG_EMPTY}${' '.repeat(empty)}${R} ${fg}${B}${pctStr}${R}`;
76+
function clampPct(value: number): number {
77+
return Math.max(0, Math.min(100, Math.round(value)));
78+
}
79+
80+
function remainingPct(percent: number | undefined | null, remaining: number, total: number): number {
81+
return percent !== undefined && percent !== null
82+
? clampPct(percent)
83+
: total > 0 ? clampPct((remaining / total) * 100) : 0;
84+
}
85+
86+
function renderBar(remainingPct: number, color: boolean, barWidth: number = BAR_WIDTH, showPct: boolean = true): string {
87+
const pct = clampPct(remainingPct);
88+
const ratio = pct / 100;
89+
const filled = Math.round(barWidth * ratio);
90+
const empty = barWidth - filled;
91+
const pctStr = `${pct}%`.padStart(4);
92+
if (!color) {
93+
const bar = `[${'█'.repeat(filled)}${'.'.repeat(empty)}]`;
94+
return showPct ? `${bar} ${pctStr}` : bar;
95+
}
96+
const [fg, bg] = remainingColors(pct);
97+
const bar = `${bg}${' '.repeat(filled)}${R}${BG_EMPTY}${' '.repeat(empty)}${R}`;
98+
return showPct ? `${bar} ${fg}${B}${pctStr}${R}` : bar;
99+
}
100+
101+
function renderMetric(
102+
label: string,
103+
remaining: number,
104+
total: number,
105+
percent: number | undefined | null,
106+
color: boolean,
107+
): string {
108+
const pct = remainingPct(percent, remaining, total);
109+
const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, total <= 0);
110+
if (total > 0) {
111+
const count = `${remaining.toLocaleString()} / ${total.toLocaleString()}`;
112+
return color ? `${D}${label}${R} ${bar} ${remainingColors(pct)[0]}${count}${R}` : `${label} ${bar} ${count}`;
113+
}
114+
return `${label} ${bar}`;
72115
}
73116

74117
function boxLine(w: number, l: string, f: string, r: string, c: boolean): string {
@@ -84,9 +127,31 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo
84127
const useColor = !config.noColor && process.stdout.isTTY === true;
85128
const L = config.region === 'cn' ? LABELS_CN : LABELS_EN;
86129

87-
const maxNameLen = models.length > 0 ? Math.max(...models.map(m => m.model_name.length)) : 16;
88-
const barVisLen = useColor ? BAR_WIDTH + 5 : BAR_WIDTH + 7;
89-
const W = Math.max(68, maxNameLen + 2 + 15 + 2 + barVisLen + 2);
130+
const rows = models.map((m) => {
131+
const displayName = displayModelName(m.model_name, config.region);
132+
const current = renderMetric(
133+
L.current,
134+
m.current_interval_usage_count,
135+
m.current_interval_total_count,
136+
m.current_interval_remaining_percent,
137+
useColor,
138+
);
139+
const weekly = renderMetric(
140+
L.weekly,
141+
m.current_weekly_usage_count,
142+
m.current_weekly_total_count,
143+
m.current_weekly_remaining_percent,
144+
useColor,
145+
);
146+
const reset = `${L.resetsIn} ${formatDuration(m.remains_time, L.now)}`;
147+
return { displayName, current, weekly, reset };
148+
});
149+
150+
const nameWidth = Math.max(6, ...rows.map(r => displayWidth(r.displayName)));
151+
const currentWidth = Math.max(...rows.map(r => displayWidth(r.current)), 18);
152+
const weeklyWidth = Math.max(...rows.map(r => displayWidth(r.weekly)), 18);
153+
const resetWidth = Math.max(...rows.map(r => displayWidth(r.reset)), 10);
154+
const W = Math.max(72, nameWidth + 2 + currentWidth + 2 + weeklyWidth + 2 + resetWidth + 4);
90155

91156
const weekRange = models.length > 0
92157
? `${formatDate(models[0]!.weekly_start_time)}${formatDate(models[0]!.weekly_end_time)}`
@@ -110,34 +175,15 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo
110175
return;
111176
}
112177

113-
for (const m of models) {
178+
for (const row of rows) {
114179
console.log(boxLine(W, '├', '─', '┤', useColor));
115180

116-
const used = m.current_interval_usage_count;
117-
const limit = m.current_interval_total_count;
118-
const usedPct = limit > 0 ? Math.round((used / limit) * 100) : 0;
119-
const weekUsed = m.current_weekly_usage_count;
120-
const weekLimit = m.current_weekly_total_count;
121-
const resets = formatDuration(m.remains_time, L.now);
122-
123-
const nameStr = m.model_name.padEnd(maxNameLen);
124-
const usageFrac = `${used.toLocaleString()} / ${limit.toLocaleString()}`;
125-
const bar = renderBar(usedPct, useColor);
126-
const line1VisLen = maxNameLen + 2 + 15 + 2 + barVisLen;
127-
128-
const line1 = useColor
129-
? `${B}${nameStr}${R} ${usageColors(usedPct)[0]}${usageFrac.padStart(15)}${R} ${bar}`
130-
: `${nameStr} ${usageFrac.padStart(15)} ${renderBar(usedPct, false)}`;
131-
console.log(boxRow(line1, W, line1VisLen, useColor));
132-
133-
const subLeft = `└ ${L.weekly} ${weekUsed.toLocaleString()} / ${weekLimit.toLocaleString()}`;
134-
const subRight = `${L.resetsIn} ${resets}`;
135-
const subGap = Math.max(2, (W - 2) - 2 - displayWidth(subLeft) - displayWidth(subRight));
136-
const subVisLen = 2 + displayWidth(subLeft) + subGap + displayWidth(subRight);
137-
const sub = useColor
138-
? ` ${D}${subLeft}${' '.repeat(subGap)}${subRight}${R}`
139-
: ` ${subLeft}${' '.repeat(subGap)}${subRight}`;
140-
console.log(boxRow(sub, W, subVisLen, useColor));
181+
const name = useColor ? `${B}${row.displayName}${R}` : row.displayName;
182+
const line = `${name}${' '.repeat(Math.max(1, nameWidth - displayWidth(row.displayName) + 2))}` +
183+
`${row.current}${' '.repeat(Math.max(1, currentWidth - displayWidth(row.current) + 2))}` +
184+
`${row.weekly}${' '.repeat(Math.max(1, weeklyWidth - displayWidth(row.weekly) + 2))}` +
185+
row.reset;
186+
console.log(boxRow(line, W, displayWidth(line), useColor));
141187
}
142188

143189
console.log(boxLine(W, '╰', '─', '╯', useColor));

src/types/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,10 @@ export interface QuotaModelRemain {
252252
remains_time: number;
253253
current_interval_total_count: number;
254254
current_interval_usage_count: number;
255+
current_interval_remaining_percent?: number;
255256
current_weekly_total_count: number;
256257
current_weekly_usage_count: number;
258+
current_weekly_remaining_percent?: number;
257259
weekly_start_time: number;
258260
weekly_end_time: number;
259261
weekly_remains_time: number;

test/auth/timeout-fix.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('detect-region: probeRegion auth style fallback', () => {
2929
it('succeeds when endpoint only accepts Bearer token', async () => {
3030
server = createMockServer({
3131
routes: {
32-
'/v1/token_plan/remains': (req) => {
32+
'/v1/api/openplatform/coding_plan/remains': (req) => {
3333
if (req.headers.get('Authorization') === 'Bearer bearer-only-key') {
3434
return jsonResponse({ base_resp: { status_code: 0 } });
3535
}
@@ -55,7 +55,7 @@ describe('detect-region: probeRegion auth style fallback', () => {
5555
it('succeeds when endpoint only accepts x-api-key header', async () => {
5656
server = createMockServer({
5757
routes: {
58-
'/v1/token_plan/remains': (req) => {
58+
'/v1/api/openplatform/coding_plan/remains': (req) => {
5959
if (req.headers.get('x-api-key') === 'xapikey-only-key') {
6060
return jsonResponse({ base_resp: { status_code: 0 } });
6161
}
@@ -80,7 +80,7 @@ describe('detect-region: probeRegion auth style fallback', () => {
8080
it('falls back to global when key is invalid for all auth styles and regions', async () => {
8181
server = createMockServer({
8282
routes: {
83-
'/v1/token_plan/remains': () =>
83+
'/v1/api/openplatform/coding_plan/remains': () =>
8484
jsonResponse({ error: 'unauthorized' }, 401),
8585
},
8686
});

test/client/endpoints.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { describe, it, expect } from 'bun:test';
22
import { quotaEndpoint } from '../../src/client/endpoints';
33

44
describe('quotaEndpoint', () => {
5-
it('uses token_plan/remains for global', () => {
6-
expect(quotaEndpoint('https://api.minimax.io')).toBe('https://api.minimax.io/v1/token_plan/remains');
5+
it('uses coding_plan/remains for global', () => {
6+
expect(quotaEndpoint('https://api.minimax.io')).toBe('https://api.minimax.io/v1/api/openplatform/coding_plan/remains');
77
});
88

9-
it('uses token_plan/remains for cn', () => {
10-
expect(quotaEndpoint('https://api.minimaxi.com')).toBe('https://api.minimaxi.com/v1/token_plan/remains');
9+
it('uses coding_plan/remains for cn', () => {
10+
expect(quotaEndpoint('https://api.minimaxi.com')).toBe('https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains');
1111
});
1212
});

test/output/quota-table.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,41 @@ function createModel(): QuotaModelRemain {
3737
};
3838
}
3939

40+
function createCodingPlanModels(): QuotaModelRemain[] {
41+
return [
42+
{
43+
model_name: 'general',
44+
start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
45+
end_time: Date.UTC(2026, 4, 31, 2, 0, 0),
46+
remains_time: 2 * 60 * 60 * 1000,
47+
current_interval_total_count: 0,
48+
current_interval_usage_count: 0,
49+
current_interval_remaining_percent: 94,
50+
current_weekly_total_count: 0,
51+
current_weekly_usage_count: 0,
52+
current_weekly_remaining_percent: 98,
53+
weekly_start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
54+
weekly_end_time: Date.UTC(2026, 5, 7, 0, 0, 0),
55+
weekly_remains_time: 6 * 24 * 60 * 60 * 1000,
56+
},
57+
{
58+
model_name: 'video',
59+
start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
60+
end_time: Date.UTC(2026, 5, 1, 0, 0, 0),
61+
remains_time: 6 * 60 * 60 * 1000,
62+
current_interval_total_count: 3,
63+
current_interval_usage_count: 3,
64+
current_interval_remaining_percent: 100,
65+
current_weekly_total_count: 21,
66+
current_weekly_usage_count: 21,
67+
current_weekly_remaining_percent: 100,
68+
weekly_start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
69+
weekly_end_time: Date.UTC(2026, 5, 7, 0, 0, 0),
70+
weekly_remains_time: 6 * 24 * 60 * 60 * 1000,
71+
},
72+
];
73+
}
74+
4075
describe('renderQuotaTable', () => {
4176
it('does not force model names to white in color mode', () => {
4277
const lines: string[] = [];
@@ -65,4 +100,33 @@ describe('renderQuotaTable', () => {
65100
expect(output).toContain('MiniMax-M2');
66101
expect(output).not.toContain(WHITE_ANSI);
67102
});
103+
104+
it('renders coding plan remaining quotas without deriving counts from percent', () => {
105+
const lines: string[] = [];
106+
const originalLog = console.log;
107+
108+
console.log = (message?: unknown) => {
109+
lines.push(String(message ?? ''));
110+
};
111+
112+
try {
113+
renderQuotaTable(createCodingPlanModels(), {
114+
...createConfig(),
115+
region: 'cn',
116+
noColor: true,
117+
});
118+
} finally {
119+
console.log = originalLog;
120+
}
121+
122+
const output = lines.join('\n');
123+
124+
expect(output).toContain('通用');
125+
expect(output).toContain('剩余 [█████████.] 94%');
126+
expect(output).toContain('周剩余 [██████████] 98%');
127+
expect(output).toContain('视频');
128+
expect(output).toContain('3 / 3');
129+
expect(output).toContain('21 / 21');
130+
expect(output).not.toContain('0 / 3');
131+
});
68132
});

test/sdk/quota.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MiniMaxSDK } from '../../src/sdk';
44
describe('MiniMaxSDK.quota', () => {
55
it('should get quota info successfully', async () => {
66
const mockFetch = mock(async (url: string) => {
7-
if (url.includes('/v1/token_plan/remains')) {
7+
if (url.includes('/v1/api/openplatform/coding_plan/remains')) {
88
return new Response(JSON.stringify({
99
model_remains: [
1010
{
@@ -14,6 +14,7 @@ describe('MiniMaxSDK.quota', () => {
1414
remains_time: 1000,
1515
current_interval_total_count: 1000,
1616
current_interval_usage_count: 500,
17+
current_interval_remaining_percent: 50,
1718
current_weekly_total_count: 5000,
1819
current_weekly_usage_count: 2000,
1920
weekly_start_time: 0,

0 commit comments

Comments
 (0)