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:
@@ -4,6 +4,5 @@ export default {
|
|||||||
type: 'local-jsx',
|
type: 'local-jsx',
|
||||||
name: 'usage',
|
name: 'usage',
|
||||||
description: 'Show plan usage limits',
|
description: 'Show plan usage limits',
|
||||||
availability: ['claude-ai'],
|
|
||||||
load: () => import('./usage.js'),
|
load: () => import('./usage.js'),
|
||||||
} satisfies Command
|
} satisfies Command
|
||||||
|
|||||||
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 { ProgressBar } from '../design-system/ProgressBar.js';
|
||||||
import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js';
|
import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js';
|
||||||
import { CodexUsage } from './CodexUsage.js';
|
import { CodexUsage } from './CodexUsage.js';
|
||||||
|
import { MiniMaxUsage } from './MiniMaxUsage.js';
|
||||||
|
import { UnsupportedUsage } from './UnsupportedUsage.js';
|
||||||
type LimitBarProps = {
|
type LimitBarProps = {
|
||||||
title: string;
|
title: string;
|
||||||
limit: RateLimit;
|
limit: RateLimit;
|
||||||
@@ -266,9 +268,26 @@ function AnthropicUsage(): React.ReactNode {
|
|||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
export function Usage(): React.ReactNode {
|
export function Usage(): React.ReactNode {
|
||||||
if (getAPIProvider() === 'codex') {
|
const provider = getAPIProvider();
|
||||||
|
if (provider === 'codex') {
|
||||||
return <CodexUsage />;
|
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 />;
|
return <AnthropicUsage />;
|
||||||
}
|
}
|
||||||
type ExtraUsageSectionProps = {
|
type ExtraUsageSectionProps = {
|
||||||
|
|||||||
21
src/services/api/__fixtures__/minimax-model-remains.json
Normal file
21
src/services/api/__fixtures__/minimax-model-remains.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
325
src/services/api/minimaxUsage.test.ts
Normal file
325
src/services/api/minimaxUsage.test.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
17
src/services/api/minimaxUsage.ts
Normal file
17
src/services/api/minimaxUsage.ts
Normal file
@@ -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'
|
||||||
142
src/services/api/minimaxUsage/fetch.ts
Normal file
142
src/services/api/minimaxUsage/fetch.ts
Normal file
@@ -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<MiniMaxUsageData> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
540
src/services/api/minimaxUsage/parse.ts
Normal file
540
src/services/api/minimaxUsage/parse.ts
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_MINIMAX_UNAVAILABLE_MESSAGE,
|
||||||
|
type MiniMaxUsageData,
|
||||||
|
type MiniMaxUsageRow,
|
||||||
|
type MiniMaxUsageSnapshot,
|
||||||
|
type MiniMaxUsageWindow,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
type RecordLike = Record<string, unknown>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
43
src/services/api/minimaxUsage/types.ts
Normal file
43
src/services/api/minimaxUsage/types.ts
Normal file
@@ -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.'
|
||||||
Reference in New Issue
Block a user