diff --git a/src/commands/usage/index.ts b/src/commands/usage/index.ts
index c3871048..c20ab4fa 100644
--- a/src/commands/usage/index.ts
+++ b/src/commands/usage/index.ts
@@ -4,6 +4,5 @@ export default {
type: 'local-jsx',
name: 'usage',
description: 'Show plan usage limits',
- availability: ['claude-ai'],
load: () => import('./usage.js'),
} satisfies Command
diff --git a/src/components/Settings/MiniMaxUsage.tsx b/src/components/Settings/MiniMaxUsage.tsx
new file mode 100644
index 00000000..c358e96f
--- /dev/null
+++ b/src/components/Settings/MiniMaxUsage.tsx
@@ -0,0 +1,249 @@
+import * as React from 'react'
+import { useEffect, useState } from 'react'
+
+import { useTerminalSize } from '../../hooks/useTerminalSize.js'
+import { Box, Text } from '../../ink.js'
+import { useKeybinding } from '../../keybindings/useKeybinding.js'
+import {
+ buildMiniMaxUsageRows,
+ fetchMiniMaxUsage,
+ type MiniMaxUsageData,
+ type MiniMaxUsageRow,
+} from '../../services/api/minimaxUsage.js'
+import { logError } from '../../utils/log.js'
+import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
+import { Byline } from '../design-system/Byline.js'
+import { ProgressBar } from '../design-system/ProgressBar.js'
+
+const RESET_COUNTDOWN_REFRESH_MS = 30_000
+const PROGRESS_BAR_WIDTH = 18
+
+type MiniMaxUsageLimitBarProps = {
+ label: string
+ usedPercent: number
+ resetsAt?: string
+ extraSubtext?: string
+ maxWidth: number
+ nowMs: number
+}
+
+function formatCountdownDuration(ms: number): string {
+ const totalMinutes = Math.max(1, Math.ceil(ms / 60_000))
+ const days = Math.floor(totalMinutes / 1_440)
+ const hours = Math.floor((totalMinutes % 1_440) / 60)
+ const minutes = totalMinutes % 60
+
+ if (days > 0) {
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`
+ }
+
+ if (hours > 0) {
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
+ }
+
+ return `${minutes}m`
+}
+
+function formatResetCountdown(
+ resetsAt: string | undefined,
+ nowMs: number,
+): string | undefined {
+ if (!resetsAt) return undefined
+
+ const resetMs = Date.parse(resetsAt)
+ if (!Number.isFinite(resetMs)) return undefined
+
+ const remainingMs = resetMs - nowMs
+ if (remainingMs <= 0) {
+ return 'Resetting now'
+ }
+
+ return `Resets in ${formatCountdownDuration(remainingMs)}`
+}
+
+function MiniMaxUsageLimitBar({
+ label,
+ usedPercent,
+ resetsAt,
+ extraSubtext,
+ maxWidth,
+ nowMs,
+}: MiniMaxUsageLimitBarProps): React.ReactNode {
+ const normalizedUsedPercent = Math.max(0, Math.min(100, usedPercent))
+ const usedText = `${Math.floor(normalizedUsedPercent)}% used`
+ const resetText = formatResetCountdown(resetsAt, nowMs)
+ const details = [usedText, extraSubtext].filter(
+ (part): part is string => Boolean(part),
+ )
+
+ return (
+
+
+ {label}
+ {resetText ? · {resetText} : null}
+
+
+
+ {details.length > 0 ? {details.join(' · ')} : null}
+
+
+ )
+}
+
+function MiniMaxUsageTextRow({
+ label,
+ value,
+}: Extract): React.ReactNode {
+ if (!value) {
+ return {label}
+ }
+
+ return (
+
+ {label}
+ · {value}
+
+ )
+}
+
+export function MiniMaxUsage(): React.ReactNode {
+ const [usage, setUsage] = useState(null)
+ const [error, setError] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [nowMs, setNowMs] = useState(() => Date.now())
+ const { columns } = useTerminalSize()
+ const availableWidth = columns - 2
+ const maxWidth = Math.min(availableWidth, 80)
+
+ const loadUsage = React.useCallback(async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ setUsage(await fetchMiniMaxUsage())
+ } catch (err) {
+ logError(err as Error)
+ setError(
+ err instanceof Error ? err.message : 'Failed to load MiniMax usage',
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void loadUsage()
+ }, [loadUsage])
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setNowMs(Date.now())
+ }, RESET_COUNTDOWN_REFRESH_MS)
+
+ return () => clearInterval(interval)
+ }, [])
+
+ useKeybinding(
+ 'settings:retry',
+ () => {
+ void loadUsage()
+ },
+ {
+ context: 'Settings',
+ isActive: !!error && !isLoading,
+ },
+ )
+
+ if (error) {
+ return (
+
+ Error: {error}
+
+
+
+
+
+
+
+ )
+ }
+
+ if (!usage) {
+ return (
+
+ Loading MiniMax usage data…
+
+
+
+
+ )
+ }
+
+ const rows =
+ usage.availability === 'available'
+ ? buildMiniMaxUsageRows(usage.snapshots)
+ : []
+
+ return (
+
+ {usage.planType ? Plan: {usage.planType} : null}
+
+ {usage.availability === 'unknown' ? (
+ {usage.message}
+ ) : rows.length === 0 ? (
+
+ No MiniMax usage windows were returned for this account.
+
+ ) : null}
+
+ {rows.map((row, index) =>
+ row.kind === 'window' ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/Settings/UnsupportedUsage.tsx b/src/components/Settings/UnsupportedUsage.tsx
new file mode 100644
index 00000000..6c22061e
--- /dev/null
+++ b/src/components/Settings/UnsupportedUsage.tsx
@@ -0,0 +1,28 @@
+import * as React from 'react'
+
+import { Box, Text } from '../../ink.js'
+import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
+
+type UnsupportedUsageProps = {
+ providerLabel: string
+}
+
+export function UnsupportedUsage({
+ providerLabel,
+}: UnsupportedUsageProps): React.ReactNode {
+ return (
+
+
+ Usage details are not currently available for {providerLabel}.
+
+
+
+
+
+ )
+}
diff --git a/src/components/Settings/Usage.tsx b/src/components/Settings/Usage.tsx
index a44c547a..df260fb8 100644
--- a/src/components/Settings/Usage.tsx
+++ b/src/components/Settings/Usage.tsx
@@ -17,6 +17,8 @@ import { Byline } from '../design-system/Byline.js';
import { ProgressBar } from '../design-system/ProgressBar.js';
import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js';
import { CodexUsage } from './CodexUsage.js';
+import { MiniMaxUsage } from './MiniMaxUsage.js';
+import { UnsupportedUsage } from './UnsupportedUsage.js';
type LimitBarProps = {
title: string;
limit: RateLimit;
@@ -266,9 +268,26 @@ function AnthropicUsage(): React.ReactNode {
;
}
export function Usage(): React.ReactNode {
- if (getAPIProvider() === 'codex') {
+ const provider = getAPIProvider();
+ if (provider === 'codex') {
return ;
}
+ if (provider === 'minimax') {
+ return ;
+ }
+ if (provider !== 'firstParty') {
+ const providerLabel = {
+ openai: 'this OpenAI-compatible provider',
+ gemini: 'Google Gemini',
+ github: 'GitHub Models',
+ mistral: 'Mistral',
+ 'nvidia-nim': 'NVIDIA NIM',
+ bedrock: 'AWS Bedrock',
+ vertex: 'Google Vertex AI',
+ foundry: 'Microsoft Foundry'
+ }[provider] ?? 'this provider';
+ return ;
+ }
return ;
}
type ExtraUsageSectionProps = {
diff --git a/src/services/api/__fixtures__/minimax-model-remains.json b/src/services/api/__fixtures__/minimax-model-remains.json
new file mode 100644
index 00000000..e245343f
--- /dev/null
+++ b/src/services/api/__fixtures__/minimax-model-remains.json
@@ -0,0 +1,21 @@
+{
+ "plan_type": "plus_highspeed",
+ "model_remains": [
+ {
+ "model_name": "MiniMax-M2.7",
+ "remains_time": 3600,
+ "current_interval_total_count": 1500,
+ "current_interval_usage_count": 1437
+ },
+ {
+ "model_name": "MiniMax-M2.7-highspeed",
+ "end_time": 1771603200000,
+ "current_interval_total_count": 2000,
+ "current_interval_usage_count": 1000
+ }
+ ],
+ "base_resp": {
+ "status_code": 0,
+ "status_msg": "success"
+ }
+}
diff --git a/src/services/api/minimaxUsage.test.ts b/src/services/api/minimaxUsage.test.ts
new file mode 100644
index 00000000..6b364d21
--- /dev/null
+++ b/src/services/api/minimaxUsage.test.ts
@@ -0,0 +1,325 @@
+import { describe, expect, test } from 'bun:test'
+import { resolve } from 'node:path'
+
+import {
+ buildMiniMaxUsageRows,
+ getMiniMaxUsageUrls,
+ normalizeMiniMaxUsagePayload,
+} from './minimaxUsage.js'
+
+const fixture = (name: string) =>
+ Bun.file(resolve(import.meta.dir, '__fixtures__', name))
+
+describe('normalizeMiniMaxUsagePayload', () => {
+ test('normalizes interval and weekly quota payloads', () => {
+ const usage = normalizeMiniMaxUsagePayload({
+ plan_type: 'plus_highspeed',
+ data: {
+ 'MiniMax-M2.7-highspeed': {
+ current_interval_usage_count: 4200,
+ max_interval_usage_count: 4500,
+ current_weekly_usage_count: 43000,
+ max_weekly_usage_count: 45000,
+ },
+ },
+ })
+
+ expect(usage).toMatchObject({
+ availability: 'available',
+ planType: 'Plus Highspeed',
+ snapshots: [
+ {
+ limitName: 'MiniMax-M2.7-highspeed',
+ windows: [
+ {
+ label: '5h limit',
+ usedPercent: 93,
+ remaining: 300,
+ total: 4500,
+ },
+ {
+ label: 'Weekly limit',
+ usedPercent: 96,
+ remaining: 2000,
+ total: 45000,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ test('normalizes daily quota payloads from generic usage records', () => {
+ const usage = normalizeMiniMaxUsagePayload({
+ models: {
+ image_01: {
+ daily_remaining: 12,
+ daily_quota: 50,
+ },
+ },
+ })
+
+ expect(usage).toMatchObject({
+ availability: 'available',
+ snapshots: [
+ {
+ limitName: 'image_01',
+ windows: [
+ {
+ label: 'Daily limit',
+ usedPercent: 76,
+ remaining: 12,
+ total: 50,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ test('normalizes MiniMax model_remains payloads from a captured fixture', async () => {
+ const payload = await fixture('minimax-model-remains.json').json()
+ const originalDateNow = Date.now
+ Date.now = () => Date.parse('2026-02-20T15:00:00.000Z')
+
+ try {
+ const usage = normalizeMiniMaxUsagePayload(payload)
+
+ expect(usage).toMatchObject({
+ availability: 'available',
+ planType: 'Plus Highspeed',
+ snapshots: [
+ {
+ limitName: 'MiniMax-M2.7',
+ windows: [
+ {
+ label: '5h limit',
+ usedPercent: 96,
+ remaining: 63,
+ total: 1500,
+ resetsAt: '2026-02-20T16:00:00.000Z',
+ },
+ ],
+ },
+ {
+ limitName: 'MiniMax-M2.7-highspeed',
+ windows: [
+ {
+ label: '5h limit',
+ usedPercent: 50,
+ remaining: 1000,
+ total: 2000,
+ resetsAt: '2026-02-20T16:00:00.000Z',
+ },
+ ],
+ },
+ ],
+ })
+ } finally {
+ Date.now = originalDateNow
+ }
+ })
+
+ test('treats current_interval_usage_count as used count for MiniMax subscription payloads', () => {
+ const usage = normalizeMiniMaxUsagePayload({
+ model_remains: [
+ {
+ current_interval_total_count: 1500,
+ current_interval_usage_count: 1,
+ model_name: 'MiniMax-M2.7',
+ },
+ ],
+ })
+
+ expect(usage).toMatchObject({
+ availability: 'available',
+ snapshots: [
+ {
+ limitName: 'MiniMax-M2.7',
+ windows: [
+ {
+ label: '5h limit',
+ usedPercent: 0,
+ remaining: 1499,
+ total: 1500,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ test('treats MiniMax usage_percent as remaining percentage', () => {
+ const usage = normalizeMiniMaxUsagePayload({
+ model_remains: [
+ {
+ model_name: 'MiniMax-M2.7-highspeed',
+ usage_percent: 96,
+ },
+ ],
+ })
+
+ expect(usage).toMatchObject({
+ availability: 'available',
+ snapshots: [
+ {
+ limitName: 'MiniMax-M2.7-highspeed',
+ windows: [
+ {
+ label: '5h limit',
+ usedPercent: 4,
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ test('returns unknown availability when no quota windows can be parsed', () => {
+ const usage = normalizeMiniMaxUsagePayload({
+ message: 'quota status unavailable',
+ ok: true,
+ })
+
+ expect(usage).toEqual({
+ availability: 'unknown',
+ planType: undefined,
+ snapshots: [],
+ message:
+ 'Usage details are not available for this MiniMax account. This plan or MiniMax endpoint may not expose quota status.',
+ })
+ })
+})
+
+describe('buildMiniMaxUsageRows', () => {
+ test('builds provider-prefixed labels and remaining subtext', () => {
+ const rows = buildMiniMaxUsageRows([
+ {
+ limitName: 'MiniMax-M2.7',
+ windows: [
+ {
+ label: '5h limit',
+ usedPercent: 20,
+ remaining: 1200,
+ total: 1500,
+ },
+ {
+ label: 'Weekly limit',
+ usedPercent: 10,
+ remaining: 13500,
+ total: 15000,
+ },
+ ],
+ },
+ {
+ limitName: 'image_01',
+ windows: [
+ {
+ label: 'Daily limit',
+ usedPercent: 76,
+ remaining: 12,
+ total: 50,
+ },
+ ],
+ },
+ ])
+
+ expect(rows).toEqual([
+ {
+ kind: 'text',
+ label: 'MiniMax-M2.7 quota',
+ value: '',
+ },
+ {
+ kind: 'window',
+ label: '5h limit',
+ usedPercent: 20,
+ resetsAt: undefined,
+ extraSubtext: '1200/1500 remaining',
+ },
+ {
+ kind: 'window',
+ label: 'Weekly limit',
+ usedPercent: 10,
+ resetsAt: undefined,
+ extraSubtext: '13500/15000 remaining',
+ },
+ {
+ kind: 'window',
+ label: 'Image 01 Daily limit',
+ usedPercent: 76,
+ resetsAt: undefined,
+ extraSubtext: '12/50 remaining',
+ },
+ ])
+ })
+})
+
+describe('MiniMax usage helpers', () => {
+ test('keeps usage endpoints on the configured provider host and path', () => {
+ expect(
+ getMiniMaxUsageUrls('https://proxy.example/providers/minimax/v1'),
+ ).toEqual([
+ 'https://proxy.example/providers/minimax/v1/token_plan/remains',
+ 'https://proxy.example/providers/minimax/v1/api/openplatform/coding_plan/remains',
+ ])
+ })
+
+ test('falls back to OPENAI_API_BASE when OPENAI_BASE_URL is unset', () => {
+ const originalBaseUrl = process.env.OPENAI_BASE_URL
+ const originalApiBase = process.env.OPENAI_API_BASE
+ delete process.env.OPENAI_BASE_URL
+ process.env.OPENAI_API_BASE = 'https://gateway.example/openai/v1'
+
+ try {
+ expect(getMiniMaxUsageUrls()).toEqual([
+ 'https://gateway.example/openai/v1/token_plan/remains',
+ 'https://gateway.example/openai/v1/api/openplatform/coding_plan/remains',
+ ])
+ } finally {
+ if (originalBaseUrl === undefined) {
+ delete process.env.OPENAI_BASE_URL
+ } else {
+ process.env.OPENAI_BASE_URL = originalBaseUrl
+ }
+
+ if (originalApiBase === undefined) {
+ delete process.env.OPENAI_API_BASE
+ } else {
+ process.env.OPENAI_API_BASE = originalApiBase
+ }
+ }
+ })
+
+ test('throws when an explicitly configured MiniMax base url is invalid', () => {
+ expect(() => getMiniMaxUsageUrls('not a url')).toThrow(
+ 'MiniMax usage base URL is invalid: not a url',
+ )
+ })
+
+ test('uses the default MiniMax base url when no provider base is configured', () => {
+ const originalBaseUrl = process.env.OPENAI_BASE_URL
+ const originalApiBase = process.env.OPENAI_API_BASE
+ delete process.env.OPENAI_BASE_URL
+ delete process.env.OPENAI_API_BASE
+
+ try {
+ expect(getMiniMaxUsageUrls()).toEqual([
+ 'https://api.minimax.io/v1/token_plan/remains',
+ 'https://api.minimax.io/v1/api/openplatform/coding_plan/remains',
+ ])
+ } finally {
+ if (originalBaseUrl === undefined) {
+ delete process.env.OPENAI_BASE_URL
+ } else {
+ process.env.OPENAI_BASE_URL = originalBaseUrl
+ }
+
+ if (originalApiBase === undefined) {
+ delete process.env.OPENAI_API_BASE
+ } else {
+ process.env.OPENAI_API_BASE = originalApiBase
+ }
+ }
+ })
+})
diff --git a/src/services/api/minimaxUsage.ts b/src/services/api/minimaxUsage.ts
new file mode 100644
index 00000000..5cea999a
--- /dev/null
+++ b/src/services/api/minimaxUsage.ts
@@ -0,0 +1,17 @@
+export type {
+ MiniMaxUsageData,
+ MiniMaxUsageRow,
+ MiniMaxUsageSnapshot,
+ MiniMaxUsageWindow,
+} from './minimaxUsage/types.js'
+
+export {
+ buildMiniMaxUsageRows,
+ normalizeMiniMaxUsagePayload,
+} from './minimaxUsage/parse.js'
+
+export {
+ fetchMiniMaxUsage,
+ getMiniMaxUsageUrls,
+ resolveMiniMaxUsageBaseUrl,
+} from './minimaxUsage/fetch.js'
diff --git a/src/services/api/minimaxUsage/fetch.ts b/src/services/api/minimaxUsage/fetch.ts
new file mode 100644
index 00000000..fe34e6ec
--- /dev/null
+++ b/src/services/api/minimaxUsage/fetch.ts
@@ -0,0 +1,142 @@
+import { logForDebugging } from '../../../utils/debug.js'
+import { getClaudeCodeUserAgent } from '../../../utils/userAgent.js'
+import {
+ DEFAULT_MINIMAX_BASE_URL,
+ DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE,
+ type MiniMaxUsageData,
+} from './types.js'
+import { normalizeMiniMaxUsagePayload } from './parse.js'
+
+function trimTrailingSlash(value: string): string {
+ return value.replace(/\/+$/, '')
+}
+
+export function resolveMiniMaxUsageBaseUrl(
+ baseUrl = process.env.OPENAI_BASE_URL ??
+ process.env.OPENAI_API_BASE ??
+ DEFAULT_MINIMAX_BASE_URL,
+): string {
+ const trimmed = baseUrl.trim()
+ return trimmed ? trimTrailingSlash(trimmed) : DEFAULT_MINIMAX_BASE_URL
+}
+
+function resolveConfiguredMiniMaxUsageBaseUrl(
+ baseUrl?: string,
+): { baseUrl: string; usedDefault: boolean } {
+ const configuredBaseUrl =
+ baseUrl ?? process.env.OPENAI_BASE_URL ?? process.env.OPENAI_API_BASE
+
+ if (!configuredBaseUrl?.trim()) {
+ return {
+ baseUrl: DEFAULT_MINIMAX_BASE_URL,
+ usedDefault: true,
+ }
+ }
+
+ return {
+ baseUrl: resolveMiniMaxUsageBaseUrl(configuredBaseUrl),
+ usedDefault: false,
+ }
+}
+
+function buildUnavailableResult(message: string): MiniMaxUsageData {
+ return {
+ availability: 'unknown',
+ snapshots: [],
+ message,
+ }
+}
+
+export function getMiniMaxUsageUrls(baseUrl?: string): string[] {
+ const { baseUrl: resolvedBaseUrl, usedDefault } =
+ resolveConfiguredMiniMaxUsageBaseUrl(baseUrl)
+
+ try {
+ const base = new URL(`${resolvedBaseUrl}/`)
+ return [
+ new URL('token_plan/remains', base).toString(),
+ new URL('api/openplatform/coding_plan/remains', base).toString(),
+ ]
+ } catch {
+ if (usedDefault) {
+ const fallbackBase = new URL(`${DEFAULT_MINIMAX_BASE_URL}/`)
+ return [
+ new URL('token_plan/remains', fallbackBase).toString(),
+ new URL('api/openplatform/coding_plan/remains', fallbackBase).toString(),
+ ]
+ }
+
+ throw new Error(
+ `MiniMax usage base URL is invalid: ${resolvedBaseUrl}`,
+ )
+ }
+}
+
+export async function fetchMiniMaxUsage(): Promise {
+ const apiKey = process.env.MINIMAX_API_KEY || process.env.OPENAI_API_KEY
+ if (!apiKey) {
+ throw new Error(
+ 'MiniMax auth is required. Set MINIMAX_API_KEY or OPENAI_API_KEY.',
+ )
+ }
+
+ const usageUrls = getMiniMaxUsageUrls()
+ const nonFatalFailures: Array<{ status: number; body: string }> = []
+ let lastFatalError: Error | null = null
+
+ for (const usageUrl of usageUrls) {
+ let response: Response
+ try {
+ response = await fetch(usageUrl, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': getClaudeCodeUserAgent(),
+ },
+ signal: AbortSignal.timeout(5000),
+ })
+ } catch (error) {
+ logForDebugging(
+ `[minimax] usage request failed for ${usageUrl}: ${error instanceof Error ? error.message : String(error)}`,
+ { level: 'warn' },
+ )
+ lastFatalError =
+ error instanceof Error ? error : new Error(String(error))
+ continue
+ }
+
+ if (!response.ok) {
+ const errorBody = await response.text().catch(() => '')
+ if ([400, 401, 403, 404].includes(response.status)) {
+ nonFatalFailures.push({ status: response.status, body: errorBody })
+ continue
+ }
+ lastFatalError = new Error(
+ `MiniMax usage error ${response.status}: ${errorBody || 'unknown error'}`,
+ )
+ continue
+ }
+
+ const normalized = normalizeMiniMaxUsagePayload(await response.json())
+ if (normalized.availability === 'available') {
+ return normalized
+ }
+ }
+
+ if (nonFatalFailures.length > 0) {
+ const latest = nonFatalFailures[nonFatalFailures.length - 1]
+ logForDebugging(
+ `[minimax] usage endpoint returned non-fatal status ${latest.status}: ${latest.body}`,
+ { level: 'warn' },
+ )
+ return buildUnavailableResult(DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE)
+ }
+
+ if (lastFatalError) {
+ throw lastFatalError
+ }
+
+ return buildUnavailableResult(DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE)
+}
diff --git a/src/services/api/minimaxUsage/parse.ts b/src/services/api/minimaxUsage/parse.ts
new file mode 100644
index 00000000..8ff94a75
--- /dev/null
+++ b/src/services/api/minimaxUsage/parse.ts
@@ -0,0 +1,540 @@
+import {
+ DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE,
+ type MiniMaxUsageData,
+ type MiniMaxUsageRow,
+ type MiniMaxUsageSnapshot,
+ type MiniMaxUsageWindow,
+} from './types.js'
+
+type RecordLike = Record
+
+type WindowSpec = {
+ label: string
+ percentKeys: string[]
+ remainingPercentKeys: string[]
+ totalKeys: string[]
+ remainingKeys: string[]
+ usedKeys: string[]
+ resetKeys: string[]
+}
+
+function isRecord(value: unknown): value is RecordLike {
+ return typeof value === 'object' && value !== null
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
+}
+
+function asNumber(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number.parseFloat(value)
+ if (Number.isFinite(parsed)) {
+ return parsed
+ }
+ }
+ return undefined
+}
+
+function clampPercent(value: number): number {
+ return Math.max(0, Math.min(100, Math.round(value)))
+}
+
+function toIsoDate(value: unknown): string | undefined {
+ if (typeof value === 'string') {
+ const parsed = Date.parse(value)
+ return Number.isNaN(parsed) ? value : new Date(parsed).toISOString()
+ }
+
+ const numeric = asNumber(value)
+ if (numeric === undefined) return undefined
+
+ const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000
+ return new Date(ms).toISOString()
+}
+
+function toResetIsoDate(value: unknown, key: string): string | undefined {
+ const numeric = asNumber(value)
+ if (/remains?_time/i.test(key) && numeric !== undefined) {
+ const ms = numeric > 604_800 ? numeric : numeric * 1000
+ return new Date(Date.now() + ms).toISOString()
+ }
+
+ return toIsoDate(value)
+}
+
+function readFirstNumber(
+ value: RecordLike,
+ keys: string[],
+): number | undefined {
+ for (const key of keys) {
+ const found = asNumber(value[key])
+ if (found !== undefined) {
+ return found
+ }
+ }
+ return undefined
+}
+
+function readResetTime(value: RecordLike, keys: string[]): string | undefined {
+ for (const key of keys) {
+ if (value[key] !== undefined) {
+ return toResetIsoDate(value[key], key)
+ }
+ }
+ return undefined
+}
+
+function containsAnyKey(value: RecordLike, keys: string[]): boolean {
+ return keys.some(key => value[key] !== undefined)
+}
+
+function capitalizeFirst(value: string): string {
+ return value ? value[0]!.toUpperCase() + value.slice(1) : value
+}
+
+function formatBucketLabel(value: string): string {
+ if (!value) return 'MiniMax'
+ const trimmed = value.trim()
+ if (!trimmed) return 'MiniMax'
+ if (/[A-Z]/.test(trimmed)) return trimmed
+ return trimmed
+ .split(/[_\s-]+/)
+ .filter(Boolean)
+ .map(part => capitalizeFirst(part.toLowerCase()))
+ .join(' ')
+}
+
+function formatPlanType(value: string | undefined): string | undefined {
+ if (!value) return undefined
+ return value
+ .split(/[_\s-]+/)
+ .filter(Boolean)
+ .map(part => capitalizeFirst(part.toLowerCase()))
+ .join(' ')
+}
+
+function normalizeWindowFromSpec(
+ value: RecordLike,
+ spec: WindowSpec,
+): MiniMaxUsageWindow | undefined {
+ const explicitUsedPercent = readFirstNumber(value, spec.percentKeys)
+ const explicitRemainingPercent = readFirstNumber(
+ value,
+ spec.remainingPercentKeys,
+ )
+ const total = readFirstNumber(value, spec.totalKeys)
+ const remaining = readFirstNumber(value, spec.remainingKeys)
+ const used = readFirstNumber(value, spec.usedKeys)
+ const derivedRemaining =
+ total !== undefined && used !== undefined ? total - used : remaining
+
+ let usedPercent: number | undefined
+ if (total !== undefined && total > 0 && used !== undefined) {
+ usedPercent = clampPercent((used / total) * 100)
+ } else if (total !== undefined && total > 0 && remaining !== undefined) {
+ usedPercent = clampPercent(((total - remaining) / total) * 100)
+ } else if (explicitUsedPercent !== undefined) {
+ usedPercent = clampPercent(explicitUsedPercent)
+ } else if (explicitRemainingPercent !== undefined) {
+ usedPercent = clampPercent(100 - explicitRemainingPercent)
+ }
+
+ if (usedPercent === undefined) {
+ return undefined
+ }
+
+ return {
+ label: spec.label,
+ usedPercent,
+ remaining: derivedRemaining,
+ total,
+ resetsAt: readResetTime(value, spec.resetKeys),
+ }
+}
+
+function normalizeGenericWindow(
+ value: RecordLike,
+): MiniMaxUsageWindow | undefined {
+ const label =
+ asString(value.label) ??
+ asString(value.name) ??
+ asString(value.window_name) ??
+ asString(value.windowName) ??
+ 'Limit'
+
+ return normalizeWindowFromSpec(value, {
+ label,
+ percentKeys: [
+ 'used_percent',
+ 'usedPercent',
+ 'utilization',
+ 'usage_percentage',
+ 'usagePercentage',
+ ],
+ remainingPercentKeys: [
+ 'usage_percent',
+ 'usagePercent',
+ 'remaining_percent',
+ 'remainingPercent',
+ 'percent_remaining',
+ 'percentRemaining',
+ ],
+ totalKeys: ['total', 'quota', 'limit', 'max', 'entitlement'],
+ remainingKeys: ['remaining', 'remain', 'remains', 'left'],
+ usedKeys: ['used', 'usage', 'consumed'],
+ resetKeys: ['resets_at', 'reset_at', 'resetsAt', 'resetAt'],
+ })
+}
+
+const WINDOW_SPECS: WindowSpec[] = [
+ {
+ label: '5h limit',
+ percentKeys: ['interval_used_percent', 'intervalUsedPercent'],
+ remainingPercentKeys: [
+ 'usage_percent',
+ 'usagePercent',
+ 'interval_remaining_percent',
+ 'intervalRemainingPercent',
+ ],
+ totalKeys: [
+ 'current_interval_total_count',
+ 'currentIntervalTotalCount',
+ 'max_interval_usage_count',
+ 'maxIntervalUsageCount',
+ 'interval_quota',
+ 'intervalQuota',
+ 'interval_limit',
+ 'intervalLimit',
+ ],
+ remainingKeys: [
+ 'current_interval_remaining_count',
+ 'currentIntervalRemainingCount',
+ 'current_interval_remains_count',
+ 'currentIntervalRemainsCount',
+ 'interval_remaining',
+ 'intervalRemaining',
+ 'remaining_interval_usage_count',
+ 'remainingIntervalUsageCount',
+ ],
+ usedKeys: [
+ 'current_interval_usage_count',
+ 'currentIntervalUsageCount',
+ 'interval_used',
+ 'intervalUsed',
+ 'current_interval_used_count',
+ 'currentIntervalUsedCount',
+ 'used_interval_usage_count',
+ 'usedIntervalUsageCount',
+ ],
+ resetKeys: [
+ 'end_time',
+ 'endTime',
+ 'interval_resets_at',
+ 'intervalResetsAt',
+ 'interval_reset_at',
+ 'intervalResetAt',
+ 'remains_time',
+ 'remainsTime',
+ ],
+ },
+ {
+ label: 'Weekly limit',
+ percentKeys: ['weekly_used_percent', 'weeklyUsedPercent'],
+ remainingPercentKeys: [
+ 'weekly_remaining_percent',
+ 'weeklyRemainingPercent',
+ ],
+ totalKeys: [
+ 'max_weekly_usage_count',
+ 'maxWeeklyUsageCount',
+ 'weekly_quota',
+ 'weeklyQuota',
+ 'weekly_limit',
+ 'weeklyLimit',
+ ],
+ remainingKeys: [
+ 'weekly_remaining',
+ 'weeklyRemaining',
+ 'remaining_weekly_usage_count',
+ 'remainingWeeklyUsageCount',
+ ],
+ usedKeys: [
+ 'current_weekly_usage_count',
+ 'currentWeeklyUsageCount',
+ 'weekly_used',
+ 'weeklyUsed',
+ 'used_weekly_usage_count',
+ 'usedWeeklyUsageCount',
+ ],
+ resetKeys: [
+ 'weekly_resets_at',
+ 'weeklyResetsAt',
+ 'weekly_reset_at',
+ 'weeklyResetAt',
+ ],
+ },
+ {
+ label: 'Daily limit',
+ percentKeys: ['daily_used_percent', 'dailyUsedPercent'],
+ remainingPercentKeys: ['daily_remaining_percent', 'dailyRemainingPercent'],
+ totalKeys: [
+ 'max_daily_usage_count',
+ 'maxDailyUsageCount',
+ 'daily_quota',
+ 'dailyQuota',
+ 'daily_limit',
+ 'dailyLimit',
+ ],
+ remainingKeys: [
+ 'daily_remaining',
+ 'dailyRemaining',
+ 'remaining_daily_usage_count',
+ 'remainingDailyUsageCount',
+ ],
+ usedKeys: [
+ 'current_daily_usage_count',
+ 'currentDailyUsageCount',
+ 'daily_used',
+ 'dailyUsed',
+ 'used_daily_usage_count',
+ 'usedDailyUsageCount',
+ ],
+ resetKeys: [
+ 'daily_resets_at',
+ 'dailyResetsAt',
+ 'daily_reset_at',
+ 'dailyResetAt',
+ ],
+ },
+]
+
+const SNAPSHOT_HINT_KEYS = [
+ 'used_percent',
+ 'usedPercent',
+ 'utilization',
+ 'usage_percentage',
+ 'usagePercentage',
+ 'total',
+ 'quota',
+ 'limit',
+ 'max',
+ 'entitlement',
+ 'remaining',
+ 'remain',
+ 'remains',
+ 'left',
+ 'usage_percent',
+ 'usagePercent',
+ 'current_interval_total_count',
+ 'current_interval_usage_count',
+ 'current_interval_remaining_count',
+ 'current_interval_remains_count',
+ 'max_interval_usage_count',
+ 'current_weekly_usage_count',
+ 'max_weekly_usage_count',
+ 'current_daily_usage_count',
+ 'max_daily_usage_count',
+]
+
+function looksLikeSnapshotRecord(value: RecordLike): boolean {
+ if (containsAnyKey(value, SNAPSHOT_HINT_KEYS)) {
+ return true
+ }
+ return WINDOW_SPECS.some(
+ spec =>
+ containsAnyKey(value, spec.totalKeys) ||
+ containsAnyKey(value, spec.remainingKeys) ||
+ containsAnyKey(value, spec.usedKeys) ||
+ containsAnyKey(value, spec.percentKeys),
+ )
+}
+
+function normalizeSnapshot(
+ value: unknown,
+ fallbackName: string,
+): MiniMaxUsageSnapshot | undefined {
+ if (!isRecord(value)) return undefined
+
+ const windows = WINDOW_SPECS.map(spec => normalizeWindowFromSpec(value, spec))
+ .filter((window): window is MiniMaxUsageWindow => window !== undefined)
+
+ if (windows.length === 0) {
+ const generic = normalizeGenericWindow(value)
+ if (generic) {
+ windows.push(generic)
+ }
+ }
+
+ if (windows.length === 0) {
+ return undefined
+ }
+
+ const limitName =
+ asString(value.limit_name) ??
+ asString(value.limitName) ??
+ asString(value.model_name) ??
+ asString(value.modelName) ??
+ asString(value.name) ??
+ fallbackName
+
+ return {
+ limitName,
+ windows,
+ }
+}
+
+function normalizeSnapshotsFromValue(
+ value: unknown,
+ fallbackName = 'MiniMax',
+): MiniMaxUsageSnapshot[] {
+ if (Array.isArray(value)) {
+ return value
+ .map((entry, index) =>
+ normalizeSnapshot(
+ entry,
+ index === 0 ? fallbackName : `${fallbackName}-${index + 1}`,
+ ),
+ )
+ .filter(
+ (snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== undefined,
+ )
+ }
+
+ if (!isRecord(value)) {
+ return []
+ }
+
+ if (looksLikeSnapshotRecord(value)) {
+ const snapshot = normalizeSnapshot(value, fallbackName)
+ return snapshot ? [snapshot] : []
+ }
+
+ return Object.entries(value)
+ .map(([key, entry]) => normalizeSnapshot(entry, key))
+ .filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== undefined)
+}
+
+function buildUnavailableResult(
+ message = DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE,
+ planType?: string,
+): MiniMaxUsageData {
+ return {
+ availability: 'unknown',
+ planType,
+ snapshots: [],
+ message,
+ }
+}
+
+export function normalizeMiniMaxUsagePayload(payload: unknown): MiniMaxUsageData {
+ if (!isRecord(payload)) {
+ return buildUnavailableResult()
+ }
+
+ const planType = formatPlanType(
+ asString(payload.plan_type) ??
+ asString(payload.planType) ??
+ asString(payload.subscription_type) ??
+ asString(payload.subscriptionType) ??
+ asString(payload.plan_name) ??
+ asString(payload.planName),
+ )
+
+ const candidates: unknown[] = [
+ payload.data,
+ payload.result,
+ payload.model_remains,
+ payload.modelRemains,
+ payload.remains,
+ payload.usage,
+ payload.quotas,
+ payload.models,
+ isRecord(payload.data) ? payload.data.model_remains : undefined,
+ isRecord(payload.data) ? payload.data.modelRemains : undefined,
+ isRecord(payload.data) ? payload.data.models : undefined,
+ isRecord(payload.data) ? payload.data.quotas : undefined,
+ isRecord(payload.data) ? payload.data.remains : undefined,
+ ]
+
+ const snapshots = candidates
+ .flatMap(candidate => normalizeSnapshotsFromValue(candidate))
+ .filter((snapshot, index, all) => {
+ const identity = `${snapshot.limitName}:${snapshot.windows.map(window => window.label).join('|')}`
+ return (
+ all.findIndex(
+ candidate =>
+ `${candidate.limitName}:${candidate.windows.map(window => window.label).join('|')}` ===
+ identity,
+ ) === index
+ )
+ })
+
+ if (snapshots.length > 0) {
+ return {
+ availability: 'available',
+ planType,
+ snapshots,
+ }
+ }
+
+ const directSnapshots = normalizeSnapshotsFromValue(payload)
+ if (directSnapshots.length > 0) {
+ return {
+ availability: 'available',
+ planType,
+ snapshots: directSnapshots,
+ }
+ }
+
+ return buildUnavailableResult(undefined, planType)
+}
+
+function buildRemainingText(window: MiniMaxUsageWindow): string | undefined {
+ if (
+ window.remaining === undefined ||
+ window.total === undefined ||
+ window.total <= 0
+ ) {
+ return undefined
+ }
+
+ return `${window.remaining}/${window.total} remaining`
+}
+
+export function buildMiniMaxUsageRows(
+ snapshots: MiniMaxUsageSnapshot[],
+): MiniMaxUsageRow[] {
+ const rows: MiniMaxUsageRow[] = []
+
+ for (const snapshot of snapshots) {
+ const bucketLabel = formatBucketLabel(snapshot.limitName)
+ const showPrefix = bucketLabel.toLowerCase() !== 'minimax'
+ const combineSingleWindow = showPrefix && snapshot.windows.length === 1
+
+ if (showPrefix && !combineSingleWindow) {
+ rows.push({
+ kind: 'text',
+ label: `${bucketLabel} quota`,
+ value: '',
+ })
+ }
+
+ for (const window of snapshot.windows) {
+ rows.push({
+ kind: 'window',
+ label: combineSingleWindow
+ ? `${bucketLabel} ${window.label}`
+ : window.label,
+ usedPercent: window.usedPercent,
+ resetsAt: window.resetsAt,
+ extraSubtext: buildRemainingText(window),
+ })
+ }
+ }
+
+ return rows
+}
diff --git a/src/services/api/minimaxUsage/types.ts b/src/services/api/minimaxUsage/types.ts
new file mode 100644
index 00000000..0e52b690
--- /dev/null
+++ b/src/services/api/minimaxUsage/types.ts
@@ -0,0 +1,43 @@
+export type MiniMaxUsageWindow = {
+ label: string
+ usedPercent: number
+ remaining?: number
+ total?: number
+ resetsAt?: string
+}
+
+export type MiniMaxUsageSnapshot = {
+ limitName: string
+ windows: MiniMaxUsageWindow[]
+}
+
+export type MiniMaxUsageRow =
+ | {
+ kind: 'window'
+ label: string
+ usedPercent: number
+ resetsAt?: string
+ extraSubtext?: string
+ }
+ | {
+ kind: 'text'
+ label: string
+ value: string
+ }
+
+export type MiniMaxUsageData =
+ | {
+ availability: 'available'
+ planType?: string
+ snapshots: MiniMaxUsageSnapshot[]
+ }
+ | {
+ availability: 'unknown'
+ planType?: string
+ snapshots: MiniMaxUsageSnapshot[]
+ message: string
+ }
+
+export const DEFAULT_MINIMAX_BASE_URL = 'https://api.minimax.io/v1'
+export const DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE =
+ 'Usage details are not available for this MiniMax account. This plan or MiniMax endpoint may not expose quota status.'