diff --git a/src/errors/api.ts b/src/errors/api.ts index 477de0a9..fc3a3be4 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -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}`, diff --git a/src/output/quota-table.ts b/src/output/quota-table.ts index 06113182..8a85eeef 100644 --- a/src/output/quota-table.ts +++ b/src/output/quota-table.ts @@ -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 = { 'general': '通用', @@ -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) { @@ -128,13 +138,18 @@ 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, @@ -142,6 +157,8 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo 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 }; diff --git a/src/types/api.ts b/src/types/api.ts index ad59e02a..4c819036 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; diff --git a/test/errors/api.test.ts b/test/errors/api.test.ts index c0722331..020482bb 100644 --- a/test/errors/api.test.ts +++ b/test/errors/api.test.ts @@ -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'); + }); }); diff --git a/test/output/quota-table.test.ts b/test/output/quota-table.test.ts index 3c0a42cb..d9a6382a 100644 --- a/test/output/quota-table.test.ts +++ b/test/output/quota-table.test.ts @@ -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, @@ -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('['); + }); });