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.'