feat(minimax): add /usage support and fix MiniMax quota parsing (#869)
* Add MiniMax usage UI and API support * Fix MiniMax usage parsing and refresh UI * Refactor MiniMax usage handling
This commit is contained in:
249
src/components/Settings/MiniMaxUsage.tsx
Normal file
249
src/components/Settings/MiniMaxUsage.tsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>{label}</Text>
|
||||
{resetText ? <Text dimColor> · {resetText}</Text> : null}
|
||||
</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<ProgressBar
|
||||
ratio={normalizedUsedPercent / 100}
|
||||
width={Math.min(PROGRESS_BAR_WIDTH, Math.max(1, maxWidth))}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
{details.length > 0 ? <Text dimColor>{details.join(' · ')}</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniMaxUsageTextRow({
|
||||
label,
|
||||
value,
|
||||
}: Extract<MiniMaxUsageRow, { kind: 'text' }>): React.ReactNode {
|
||||
if (!value) {
|
||||
return <Text bold>{label}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text bold>{label}</Text>
|
||||
<Text dimColor> · {value}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function MiniMaxUsage(): React.ReactNode {
|
||||
const [usage, setUsage] = useState<MiniMaxUsageData | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">Error: {error}</Text>
|
||||
<Text dimColor>
|
||||
<Byline>
|
||||
<ConfigurableShortcutHint
|
||||
action="settings:retry"
|
||||
context="Settings"
|
||||
fallback="r"
|
||||
description="retry"
|
||||
/>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text dimColor>Loading MiniMax usage data…</Text>
|
||||
<Text dimColor>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const rows =
|
||||
usage.availability === 'available'
|
||||
? buildMiniMaxUsageRows(usage.snapshots)
|
||||
: []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} width="100%">
|
||||
{usage.planType ? <Text dimColor>Plan: {usage.planType}</Text> : null}
|
||||
|
||||
{usage.availability === 'unknown' ? (
|
||||
<Text dimColor>{usage.message}</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Text dimColor>
|
||||
No MiniMax usage windows were returned for this account.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{rows.map((row, index) =>
|
||||
row.kind === 'window' ? (
|
||||
<MiniMaxUsageLimitBar
|
||||
key={`${row.label}-${index}`}
|
||||
label={row.label}
|
||||
usedPercent={row.usedPercent}
|
||||
resetsAt={row.resetsAt}
|
||||
extraSubtext={row.extraSubtext}
|
||||
maxWidth={maxWidth}
|
||||
nowMs={nowMs}
|
||||
/>
|
||||
) : (
|
||||
<MiniMaxUsageTextRow
|
||||
key={`${row.label}-${index}`}
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
<Text dimColor>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
28
src/components/Settings/UnsupportedUsage.tsx
Normal file
28
src/components/Settings/UnsupportedUsage.tsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text dimColor>
|
||||
Usage details are not currently available for {providerLabel}.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
</Box>;
|
||||
}
|
||||
export function Usage(): React.ReactNode {
|
||||
if (getAPIProvider() === 'codex') {
|
||||
const provider = getAPIProvider();
|
||||
if (provider === 'codex') {
|
||||
return <CodexUsage />;
|
||||
}
|
||||
if (provider === 'minimax') {
|
||||
return <MiniMaxUsage />;
|
||||
}
|
||||
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 <UnsupportedUsage providerLabel={providerLabel} />;
|
||||
}
|
||||
return <AnthropicUsage />;
|
||||
}
|
||||
type ExtraUsageSectionProps = {
|
||||
|
||||
Reference in New Issue
Block a user