diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index b8337a0a..c1a608aa 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -41,7 +41,7 @@ export function vlmEndpoint(baseUrl: string): string { export function quotaEndpoint(baseUrl: string): string { // Quota endpoint uses api subdomain const host = baseUrl.includes('minimaxi.com') ? 'https://api.minimaxi.com' : 'https://api.minimax.io'; - return `${host}/v1/token_plan/remains`; + return `${host}/v1/api/openplatform/coding_plan/remains`; } export function fileUploadEndpoint(baseUrl: string): string { diff --git a/src/config/detect-region.ts b/src/config/detect-region.ts index 25d86c70..e8283c12 100644 --- a/src/config/detect-region.ts +++ b/src/config/detect-region.ts @@ -1,7 +1,7 @@ import { REGIONS, type Region } from "./schema"; import { readConfigFile, writeConfigFile } from "./loader"; -const QUOTA_PATH = "/v1/token_plan/remains"; +const QUOTA_PATH = "/v1/api/openplatform/coding_plan/remains"; function quotaUrl(region: Region): string { return REGIONS[region] + QUOTA_PATH; diff --git a/src/output/quota-table.ts b/src/output/quota-table.ts index d85f5752..06113182 100644 --- a/src/output/quota-table.ts +++ b/src/output/quota-table.ts @@ -16,23 +16,34 @@ const BG_YELLOW = '\x1b[48;2;202;138;4m'; const BG_RED = '\x1b[48;2;220;38;38m'; const BG_EMPTY = '\x1b[48;2;55;65;81m'; -function usageColors(usedPct: number): [string, string] { - if (usedPct < 50) return [FG_GREEN, BG_GREEN]; - if (usedPct <= 80) return [FG_YELLOW, BG_YELLOW]; +function remainingColors(remainingPct: number): [string, string] { + if (remainingPct >= 50) return [FG_GREEN, BG_GREEN]; + if (remainingPct >= 20) return [FG_YELLOW, BG_YELLOW]; return [FG_RED, BG_RED]; } interface Labels { dashboard: string; week: string; + current: string; weekly: string; resetsIn: string; noData: string; now: string; } -const LABELS_EN: Labels = { dashboard: 'TokenPlan Quota', week: 'Week', weekly: 'Weekly', resetsIn: 'Resets in', noData: 'No quota data available.', now: 'now' }; -const LABELS_CN: Labels = { dashboard: 'TokenPlan 配额面板', week: '周期', 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' }; +const LABELS_CN: Labels = { dashboard: 'TokenPlan 配额面板', week: '周期', current: '剩余', weekly: '周剩余', resetsIn: '重置', noData: '暂无配额数据', now: '即将' }; + +const MODEL_NAME_CN: Record = { + 'general': '通用', + 'video': '视频', +}; + +function displayModelName(name: string, region: string): string { + if (region !== 'cn') return name; + return MODEL_NAME_CN[name] ?? name; +} function formatDuration(ms: number, nowLabel: string): string { if (ms <= 0) return nowLabel; @@ -60,15 +71,47 @@ function displayWidth(s: string): number { } const BAR_WIDTH = 16; +const COMPACT_BAR_WIDTH = 10; -function renderBar(usedPct: number, color: boolean): string { - const ratio = Math.max(0, Math.min(100, usedPct)) / 100; - const filled = Math.round(BAR_WIDTH * ratio); - const empty = BAR_WIDTH - filled; - const pctStr = `${usedPct}%`.padStart(4); - if (!color) return `[${'█'.repeat(filled)}${'.'.repeat(empty)}] ${pctStr}`; - const [fg, bg] = usageColors(usedPct); - return `${bg}${' '.repeat(filled)}${R}${BG_EMPTY}${' '.repeat(empty)}${R} ${fg}${B}${pctStr}${R}`; +function clampPct(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function remainingPct(percent: number | undefined | null, remaining: number, total: number): number { + return percent !== undefined && percent !== null + ? clampPct(percent) + : total > 0 ? clampPct((remaining / total) * 100) : 0; +} + +function renderBar(remainingPct: number, color: boolean, barWidth: number = BAR_WIDTH, showPct: boolean = true): string { + const pct = clampPct(remainingPct); + const ratio = pct / 100; + const filled = Math.round(barWidth * ratio); + const empty = barWidth - filled; + const pctStr = `${pct}%`.padStart(4); + if (!color) { + const bar = `[${'█'.repeat(filled)}${'.'.repeat(empty)}]`; + return showPct ? `${bar} ${pctStr}` : bar; + } + const [fg, bg] = remainingColors(pct); + const bar = `${bg}${' '.repeat(filled)}${R}${BG_EMPTY}${' '.repeat(empty)}${R}`; + return showPct ? `${bar} ${fg}${B}${pctStr}${R}` : bar; +} + +function renderMetric( + label: string, + remaining: number, + total: number, + percent: number | undefined | null, + color: boolean, +): string { + const pct = remainingPct(percent, remaining, total); + const bar = renderBar(pct, color, COMPACT_BAR_WIDTH, total <= 0); + if (total > 0) { + const count = `${remaining.toLocaleString()} / ${total.toLocaleString()}`; + return color ? `${D}${label}${R} ${bar} ${remainingColors(pct)[0]}${count}${R}` : `${label} ${bar} ${count}`; + } + return `${label} ${bar}`; } 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 const useColor = !config.noColor && process.stdout.isTTY === true; const L = config.region === 'cn' ? LABELS_CN : LABELS_EN; - const maxNameLen = models.length > 0 ? Math.max(...models.map(m => m.model_name.length)) : 16; - const barVisLen = useColor ? BAR_WIDTH + 5 : BAR_WIDTH + 7; - const W = Math.max(68, maxNameLen + 2 + 15 + 2 + barVisLen + 2); + const rows = models.map((m) => { + const displayName = displayModelName(m.model_name, config.region); + const current = renderMetric( + L.current, + m.current_interval_usage_count, + m.current_interval_total_count, + m.current_interval_remaining_percent, + useColor, + ); + const weekly = renderMetric( + L.weekly, + m.current_weekly_usage_count, + m.current_weekly_total_count, + m.current_weekly_remaining_percent, + useColor, + ); + const reset = `${L.resetsIn} ${formatDuration(m.remains_time, L.now)}`; + return { displayName, current, weekly, reset }; + }); + + const nameWidth = Math.max(6, ...rows.map(r => displayWidth(r.displayName))); + const currentWidth = Math.max(...rows.map(r => displayWidth(r.current)), 18); + const weeklyWidth = Math.max(...rows.map(r => displayWidth(r.weekly)), 18); + const resetWidth = Math.max(...rows.map(r => displayWidth(r.reset)), 10); + const W = Math.max(72, nameWidth + 2 + currentWidth + 2 + weeklyWidth + 2 + resetWidth + 4); const weekRange = models.length > 0 ? `${formatDate(models[0]!.weekly_start_time)} — ${formatDate(models[0]!.weekly_end_time)}` @@ -110,34 +175,15 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo return; } - for (const m of models) { + for (const row of rows) { console.log(boxLine(W, '├', '─', '┤', useColor)); - const used = m.current_interval_usage_count; - const limit = m.current_interval_total_count; - const usedPct = limit > 0 ? Math.round((used / limit) * 100) : 0; - const weekUsed = m.current_weekly_usage_count; - const weekLimit = m.current_weekly_total_count; - const resets = formatDuration(m.remains_time, L.now); - - const nameStr = m.model_name.padEnd(maxNameLen); - const usageFrac = `${used.toLocaleString()} / ${limit.toLocaleString()}`; - const bar = renderBar(usedPct, useColor); - const line1VisLen = maxNameLen + 2 + 15 + 2 + barVisLen; - - const line1 = useColor - ? `${B}${nameStr}${R} ${usageColors(usedPct)[0]}${usageFrac.padStart(15)}${R} ${bar}` - : `${nameStr} ${usageFrac.padStart(15)} ${renderBar(usedPct, false)}`; - console.log(boxRow(line1, W, line1VisLen, useColor)); - - const subLeft = `└ ${L.weekly} ${weekUsed.toLocaleString()} / ${weekLimit.toLocaleString()}`; - const subRight = `${L.resetsIn} ${resets}`; - const subGap = Math.max(2, (W - 2) - 2 - displayWidth(subLeft) - displayWidth(subRight)); - const subVisLen = 2 + displayWidth(subLeft) + subGap + displayWidth(subRight); - const sub = useColor - ? ` ${D}${subLeft}${' '.repeat(subGap)}${subRight}${R}` - : ` ${subLeft}${' '.repeat(subGap)}${subRight}`; - console.log(boxRow(sub, W, subVisLen, useColor)); + const name = useColor ? `${B}${row.displayName}${R}` : row.displayName; + const line = `${name}${' '.repeat(Math.max(1, nameWidth - displayWidth(row.displayName) + 2))}` + + `${row.current}${' '.repeat(Math.max(1, currentWidth - displayWidth(row.current) + 2))}` + + `${row.weekly}${' '.repeat(Math.max(1, weeklyWidth - displayWidth(row.weekly) + 2))}` + + row.reset; + console.log(boxRow(line, W, displayWidth(line), useColor)); } console.log(boxLine(W, '╰', '─', '╯', useColor)); diff --git a/src/types/api.ts b/src/types/api.ts index ac0e9984..ad59e02a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -252,8 +252,10 @@ export interface QuotaModelRemain { remains_time: number; current_interval_total_count: number; current_interval_usage_count: number; + current_interval_remaining_percent?: number; current_weekly_total_count: number; current_weekly_usage_count: number; + current_weekly_remaining_percent?: number; weekly_start_time: number; weekly_end_time: number; weekly_remains_time: number; diff --git a/test/auth/timeout-fix.test.ts b/test/auth/timeout-fix.test.ts index b26c08ab..13ac7d7d 100644 --- a/test/auth/timeout-fix.test.ts +++ b/test/auth/timeout-fix.test.ts @@ -29,7 +29,7 @@ describe('detect-region: probeRegion auth style fallback', () => { it('succeeds when endpoint only accepts Bearer token', async () => { server = createMockServer({ routes: { - '/v1/token_plan/remains': (req) => { + '/v1/api/openplatform/coding_plan/remains': (req) => { if (req.headers.get('Authorization') === 'Bearer bearer-only-key') { return jsonResponse({ base_resp: { status_code: 0 } }); } @@ -55,7 +55,7 @@ describe('detect-region: probeRegion auth style fallback', () => { it('succeeds when endpoint only accepts x-api-key header', async () => { server = createMockServer({ routes: { - '/v1/token_plan/remains': (req) => { + '/v1/api/openplatform/coding_plan/remains': (req) => { if (req.headers.get('x-api-key') === 'xapikey-only-key') { return jsonResponse({ base_resp: { status_code: 0 } }); } @@ -80,7 +80,7 @@ describe('detect-region: probeRegion auth style fallback', () => { it('falls back to global when key is invalid for all auth styles and regions', async () => { server = createMockServer({ routes: { - '/v1/token_plan/remains': () => + '/v1/api/openplatform/coding_plan/remains': () => jsonResponse({ error: 'unauthorized' }, 401), }, }); diff --git a/test/client/endpoints.test.ts b/test/client/endpoints.test.ts index 015ba3e7..2cba0d18 100644 --- a/test/client/endpoints.test.ts +++ b/test/client/endpoints.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect } from 'bun:test'; import { quotaEndpoint } from '../../src/client/endpoints'; describe('quotaEndpoint', () => { - it('uses token_plan/remains for global', () => { - expect(quotaEndpoint('https://api.minimax.io')).toBe('https://api.minimax.io/v1/token_plan/remains'); + it('uses coding_plan/remains for global', () => { + expect(quotaEndpoint('https://api.minimax.io')).toBe('https://api.minimax.io/v1/api/openplatform/coding_plan/remains'); }); - it('uses token_plan/remains for cn', () => { - expect(quotaEndpoint('https://api.minimaxi.com')).toBe('https://api.minimaxi.com/v1/token_plan/remains'); + it('uses coding_plan/remains for cn', () => { + expect(quotaEndpoint('https://api.minimaxi.com')).toBe('https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains'); }); }); diff --git a/test/output/quota-table.test.ts b/test/output/quota-table.test.ts index 36f0b8d9..3c0a42cb 100644 --- a/test/output/quota-table.test.ts +++ b/test/output/quota-table.test.ts @@ -37,6 +37,41 @@ function createModel(): QuotaModelRemain { }; } +function createCodingPlanModels(): QuotaModelRemain[] { + return [ + { + 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: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 94, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 98, + 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, + }, + { + model_name: 'video', + start_time: Date.UTC(2026, 4, 31, 0, 0, 0), + end_time: Date.UTC(2026, 5, 1, 0, 0, 0), + remains_time: 6 * 60 * 60 * 1000, + current_interval_total_count: 3, + current_interval_usage_count: 3, + current_interval_remaining_percent: 100, + current_weekly_total_count: 21, + current_weekly_usage_count: 21, + current_weekly_remaining_percent: 100, + 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, + }, + ]; +} + describe('renderQuotaTable', () => { it('does not force model names to white in color mode', () => { const lines: string[] = []; @@ -65,4 +100,33 @@ describe('renderQuotaTable', () => { expect(output).toContain('MiniMax-M2'); expect(output).not.toContain(WHITE_ANSI); }); + + it('renders coding plan remaining quotas without deriving counts from percent', () => { + 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'); + + expect(output).toContain('通用'); + expect(output).toContain('剩余 [█████████.] 94%'); + expect(output).toContain('周剩余 [██████████] 98%'); + expect(output).toContain('视频'); + expect(output).toContain('3 / 3'); + expect(output).toContain('21 / 21'); + expect(output).not.toContain('0 / 3'); + }); }); diff --git a/test/sdk/quota.test.ts b/test/sdk/quota.test.ts index fbc089a4..46e9abf7 100644 --- a/test/sdk/quota.test.ts +++ b/test/sdk/quota.test.ts @@ -4,7 +4,7 @@ import { MiniMaxSDK } from '../../src/sdk'; describe('MiniMaxSDK.quota', () => { it('should get quota info successfully', async () => { const mockFetch = mock(async (url: string) => { - if (url.includes('/v1/token_plan/remains')) { + if (url.includes('/v1/api/openplatform/coding_plan/remains')) { return new Response(JSON.stringify({ model_remains: [ { @@ -14,6 +14,7 @@ describe('MiniMaxSDK.quota', () => { remains_time: 1000, current_interval_total_count: 1000, current_interval_usage_count: 500, + current_interval_remaining_percent: 50, current_weekly_total_count: 5000, current_weekly_usage_count: 2000, weekly_start_time: 0,