Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/errors/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ export function mapApiError(status: number, body: ApiErrorBody, url?: string): C
);
}

// MiniMax insufficient quota
if (apiCode === 1028 || apiCode === 1030) {
// MiniMax insufficient quota / weekly usage limit (2056)
if (apiCode === 1028 || apiCode === 1030 || apiCode === 2056) {
const hint = planHintForUrl(url);
return new CLIError(
`Quota exhausted. ${apiMsg}`,
Expand Down
23 changes: 20 additions & 3 deletions src/output/quota-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ interface Labels {
resetsIn: string;
noData: string;
now: string;
notInPlan: string;
}

const LABELS_EN: Labels = { dashboard: 'TokenPlan Quota', week: 'Week', current: 'Left', weekly: 'Wk left', resetsIn: 'Reset', noData: 'No quota data available.', now: 'now' };
const LABELS_CN: Labels = { dashboard: 'TokenPlan 配额面板', week: '周期', current: '剩余', weekly: '周剩余', resetsIn: '重置', noData: '暂无配额数据', now: '即将' };
const LABELS_EN: Labels = { dashboard: 'TokenPlan Quota', week: 'Week', current: 'Left', weekly: 'Wk left', resetsIn: 'Reset', noData: 'No quota data available.', now: 'now', notInPlan: 'not in plan' };
const LABELS_CN: Labels = { dashboard: 'TokenPlan 配额面板', week: '周期', current: '剩余', weekly: '周剩余', resetsIn: '重置', noData: '暂无配额数据', now: '即将', notInPlan: '不在当前套餐中' };

const MODEL_NAME_CN: Record<string, string> = {
'general': '通用',
Expand Down Expand Up @@ -98,13 +99,22 @@ function renderBar(remainingPct: number, color: boolean, barWidth: number = BAR_
return showPct ? `${bar} ${fg}${B}${pctStr}${R}` : bar;
}

// Server status for a model not bundled in the plan: counts read 0/0 with
// percent 100, so only status distinguishes it from an in-plan 0/0 row (=1).
const STATUS_NOT_IN_PLAN = 3;

function renderMetric(
label: string,
remaining: number,
total: number,
percent: number | undefined | null,
color: boolean,
status: number | undefined | null,
notInPlanLabel: string,
): string {
if (status === STATUS_NOT_IN_PLAN) {
return color ? `${D}${label}${R} ${FG_RED}${notInPlanLabel}${R}` : `${label} ${notInPlanLabel}`;
}
const pct = remainingPct(percent, remaining, total);
const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, total <= 0);
if (total > 0) {
Expand All @@ -128,20 +138,27 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo
const L = config.region === 'cn' ? LABELS_CN : LABELS_EN;

const rows = models.map((m) => {
const displayName = displayModelName(m.model_name, config.region);
const baseName = displayModelName(m.model_name, config.region);
const boost = m.interval_boost_permille;
const boostTag = (boost && boost > 1000) ? ` ×${boost / 1000}` : '';
const displayName = baseName + boostTag;
const current = renderMetric(
L.current,
m.current_interval_usage_count,
m.current_interval_total_count,
m.current_interval_remaining_percent,
useColor,
m.current_interval_status,
L.notInPlan,
);
const weekly = renderMetric(
L.weekly,
m.current_weekly_usage_count,
m.current_weekly_total_count,
m.current_weekly_remaining_percent,
useColor,
m.current_weekly_status,
L.notInPlan,
);
const reset = `${L.resetsIn} ${formatDuration(m.remains_time, L.now)}`;
return { displayName, current, weekly, reset };
Expand Down
4 changes: 4 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,13 @@ export interface QuotaModelRemain {
current_interval_total_count: number;
current_interval_usage_count: number;
current_interval_remaining_percent?: number;
current_interval_status?: number;
current_weekly_total_count: number;
current_weekly_usage_count: number;
current_weekly_remaining_percent?: number;
current_weekly_status?: number;
interval_boost_permille?: number;
weekly_boost_permille?: number;
weekly_start_time: number;
weekly_end_time: number;
weekly_remains_time: number;
Expand Down
13 changes: 13 additions & 0 deletions test/errors/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,17 @@ describe('mapApiError', () => {
const err = mapApiError(500, { base_resp: { status_code: 0, status_msg: 'something broke' } });
expect(err.message).toContain('something broke');
});

it('maps MiniMax usage-limit code 2056 to QUOTA with upgrade hint', () => {
// issue #173: mmx video generate rejected with 2056 / (0/0 used)
const err = mapApiError(
400,
{ base_resp: { status_code: 2056, status_msg: 'weekly usage limit reached (0/0 used)' } },
'https://api.minimaxi.com/v1/video_generation',
);
expect(err.exitCode).toBe(ExitCode.QUOTA);
expect(err.message).toContain('0/0 used');
expect(err.hint).toContain('quota show');
expect(err.hint).toContain('Upgrade plan');
});
});
102 changes: 102 additions & 0 deletions test/output/quota-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ function createCodingPlanModels(): QuotaModelRemain[] {
current_interval_total_count: 0,
current_interval_usage_count: 0,
current_interval_remaining_percent: 94,
current_interval_status: 1,
current_weekly_total_count: 0,
current_weekly_usage_count: 0,
current_weekly_remaining_percent: 98,
current_weekly_status: 1,
interval_boost_permille: 2000,
weekly_boost_permille: 2000,
weekly_start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
weekly_end_time: Date.UTC(2026, 5, 7, 0, 0, 0),
weekly_remains_time: 6 * 24 * 60 * 60 * 1000,
Expand Down Expand Up @@ -129,4 +133,102 @@ describe('renderQuotaTable', () => {
expect(output).toContain('21 / 21');
expect(output).not.toContain('0 / 3');
});

it('renders boost multiplier when boost_permille > 1000', () => {
const lines: string[] = [];
const originalLog = console.log;

console.log = (message?: unknown) => {
lines.push(String(message ?? ''));
};

try {
renderQuotaTable(createCodingPlanModels(), {
...createConfig(),
region: 'cn',
noColor: true,
});
} finally {
console.log = originalLog;
}

const output = lines.join('\n');

// general model has interval_boost_permille=2000 => ×2 prefix
expect(output).toContain('通用 ×2');
// video model has no boost field => no ×2 on its row
// ensure the video line is still present (so the negative check is meaningful)
expect(output).toContain('视频');
});

it('omits boost multiplier when boost_permille is missing', () => {
const modelsNoBoost: QuotaModelRemain[] = [{
model_name: 'general',
start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
end_time: Date.UTC(2026, 4, 31, 2, 0, 0),
remains_time: 2 * 60 * 60 * 1000,
current_interval_total_count: 100,
current_interval_usage_count: 50,
current_interval_remaining_percent: 50,
current_weekly_total_count: 1000,
current_weekly_usage_count: 200,
current_weekly_remaining_percent: 80,
weekly_start_time: Date.UTC(2026, 4, 31, 0, 0, 0),
weekly_end_time: Date.UTC(2026, 5, 7, 0, 0, 0),
weekly_remains_time: 6 * 24 * 60 * 60 * 1000,
}];

const lines: string[] = [];
const originalLog = console.log;

console.log = (message?: unknown) => {
lines.push(String(message ?? ''));
};

try {
renderQuotaTable(modelsNoBoost, {
...createConfig(),
region: 'cn',
noColor: true,
});
} finally {
console.log = originalLog;
}

const output = lines.join('\n');

expect(output).toContain('通用');
expect(output).not.toContain('×2');
});

it('renders "not in plan" for status=3 rows instead of a misleading 100%', () => {
// issue #173: a plan without video. The server marks the unavailable row
// status=3 (counts 0/0, percent 100); the in-plan general row stays status=1.
const base = createCodingPlanModels();
const models: QuotaModelRemain[] = [
{ ...base[0]!, current_interval_status: 1, current_weekly_status: 1 },
{ ...base[1]!, current_interval_status: 3, current_weekly_status: 3 },
];

const lines: string[] = [];
const originalLog = console.log;
console.log = (message?: unknown) => {
lines.push(String(message ?? ''));
};
try {
renderQuotaTable(models, { ...createConfig(), region: 'cn', noColor: true });
} finally {
console.log = originalLog;
}

// video (status=3) says "not in plan"; its percent/bar must not leak through
const videoLine = lines.find((l) => l.includes('视频')) ?? '';
expect(videoLine).toContain('不在当前套餐中');
expect(videoLine).not.toContain('100%');
expect(videoLine).not.toContain('[');
// the in-plan general row (status=1) still renders its bar
const generalLine = lines.find((l) => l.includes('通用')) ?? '';
expect(generalLine).not.toContain('不在当前套餐中');
expect(generalLine).toContain('[');
});
});
Loading