diff --git a/packages/app/src/components/icons/minimax-icon.tsx b/packages/app/src/components/icons/minimax-icon.tsx new file mode 100644 index 000000000..11847d248 --- /dev/null +++ b/packages/app/src/components/icons/minimax-icon.tsx @@ -0,0 +1,14 @@ +import Svg, { Path } from "react-native-svg"; + +interface MiniMaxIconProps { + size?: number; + color?: string; +} + +export function MiniMaxIcon({ size = 16, color = "currentColor" }: MiniMaxIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index 80727b59c..956e40d0b 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -12,6 +12,7 @@ describe("resolveProviderIconName", () => { expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" }); expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" }); expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" }); + expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" }); }); it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 443abb50e..92d8c1ca3 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -4,6 +4,7 @@ import { SvgXml } from "react-native-svg"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; +import { MiniMaxIcon } from "@/components/icons/minimax-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; import { PiIcon } from "@/components/icons/pi-icon"; @@ -22,6 +23,7 @@ const BUILTIN_PROVIDER_ICONS: Record = { codex: CodexIcon as unknown as ProviderIconComponent, copilot: CopilotIcon as unknown as ProviderIconComponent, kiro: PackagePlus, + minimax: MiniMaxIcon as unknown as ProviderIconComponent, omp: OmpIcon as unknown as ProviderIconComponent, opencode: OpenCodeIcon as unknown as ProviderIconComponent, pi: PiIcon as unknown as ProviderIconComponent, diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 1e0819850..43c65685d 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -3,6 +3,7 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [ "codex", "copilot", "kiro", + "minimax", "omp", "opencode", "pi", diff --git a/packages/server/src/services/quota-fetcher/manifest.ts b/packages/server/src/services/quota-fetcher/manifest.ts index 220bd4757..11578eccd 100644 --- a/packages/server/src/services/quota-fetcher/manifest.ts +++ b/packages/server/src/services/quota-fetcher/manifest.ts @@ -9,6 +9,7 @@ import { CopilotQuotaProvider } from "./providers/copilot.js"; import { CursorQuotaProvider } from "./providers/cursor.js"; import { GrokQuotaProvider } from "./providers/grok.js"; import { KimiQuotaProvider } from "./providers/kimi.js"; +import { MiniMaxQuotaProvider } from "./providers/minimax.js"; import { ZaiQuotaProvider } from "./providers/zai.js"; export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry[] = [ @@ -48,6 +49,10 @@ export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry providerId: "kimi", create: (options) => new KimiQuotaProvider({ logger: options.logger, fetch: options.fetch }), }, + { + providerId: "minimax", + create: (options) => new MiniMaxQuotaProvider({ logger: options.logger, fetch: options.fetch }), + }, ]; export function createProviderUsageFetchers( diff --git a/packages/server/src/services/quota-fetcher/providers/minimax.ts b/packages/server/src/services/quota-fetcher/providers/minimax.ts new file mode 100644 index 000000000..21e0f0581 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/providers/minimax.ts @@ -0,0 +1,275 @@ +import { existsSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; +import { z } from "zod"; +import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js"; +import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; +import { + ApiNumberSchema, + ApiOptionalStringSchema, + fetchProviderApi, + unavailableUsage, + windowFromUsedPct, +} from "../usage.js"; + +const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io"; +const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com"; +const MINIMAX_CREDENTIALS_PATH = join(homedir(), ".mmx", "credentials.json"); +const MINIMAX_CONFIG_PATH = join(homedir(), ".mmx", "config.json"); + +const MiniMaxModelRemainSchema = z.object({ + model_name: ApiOptionalStringSchema, + start_time: ApiNumberSchema.optional(), + end_time: ApiNumberSchema.optional(), + remains_time: ApiNumberSchema.optional(), + current_interval_total_count: ApiNumberSchema.optional(), + current_interval_usage_count: ApiNumberSchema.optional(), + current_interval_remaining_percent: ApiNumberSchema.optional(), + current_weekly_total_count: ApiNumberSchema.optional(), + current_weekly_usage_count: ApiNumberSchema.optional(), + current_weekly_remaining_percent: ApiNumberSchema.optional(), + current_interval_status: ApiNumberSchema.optional(), + current_weekly_status: ApiNumberSchema.optional(), + weekly_start_time: ApiNumberSchema.optional(), + weekly_end_time: ApiNumberSchema.optional(), + weekly_remains_time: ApiNumberSchema.optional(), + weekly_boost_permille: ApiNumberSchema.optional(), +}); + +const MiniMaxQuotaResponseSchema = z.object({ + model_remains: z.array(MiniMaxModelRemainSchema).optional(), +}); + +const MiniMaxCredentialsSchema = z.object({ + access_token: z.string().optional(), + refresh_token: z.string().optional(), + expires_at: ApiOptionalStringSchema, + resource_url: ApiOptionalStringSchema, +}); + +const MiniMaxConfigSchema = z.object({ + api_key: z.string().optional(), + region: z.string().optional(), + base_url: ApiOptionalStringSchema, + oauth: MiniMaxCredentialsSchema.optional(), +}); + +type MiniMaxModelRemain = z.infer; + +interface MiniMaxResolvedAuth { + token: string; + baseUrl: string; +} + +interface MiniMaxQuotaProviderOptions { + logger: Logger; + fetch?: ProviderApiFetch; + configPath?: string; + credentialsPath?: string; + env?: NodeJS.ProcessEnv; + now?: () => number; +} + +function resolveBaseUrl(input: { baseUrl?: string; region?: string }): string { + const explicit = input.baseUrl; + if (explicit && explicit.startsWith("http")) return explicit; + if (input.region === "cn") return MINIMAX_CN_BASE_URL; + return MINIMAX_GLOBAL_BASE_URL; +} + +function computeUsedPct( + remaining: number | null | undefined, + total: number | null | undefined, +): number | null { + if (typeof remaining !== "number" || typeof total !== "number") return null; + if (!Number.isFinite(total) || total <= 0) return null; + if (!Number.isFinite(remaining)) return null; + const used = total - remaining; + return Math.max(0, Math.min(100, (used / total) * 100)); +} + +function epochMsToIso(value: number | null | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; + return new Date(value).toISOString(); +} + +function toneForStatus(status: number | null | undefined): ProviderUsageWindow["tone"] { + if (status === 2) return "danger"; + if (status === 3) return "default"; + return "ok"; +} + +function toIntervalWindow( + modelName: string, + model: MiniMaxModelRemain, +): ProviderUsageWindow | null { + const total = model.current_interval_total_count ?? null; + const used = model.current_interval_usage_count ?? null; + const remainingPercent = model.current_interval_remaining_percent ?? null; + const usedPct = + typeof remainingPercent === "number" && Number.isFinite(remainingPercent) + ? Math.max(0, Math.min(100, 100 - remainingPercent)) + : computeUsedPct( + typeof total === "number" && typeof used === "number" ? total - used : null, + total, + ); + if (usedPct === null) return null; + return windowFromUsedPct({ + id: `interval_${modelName}`, + label: `${modelName} · Interval`, + utilizationPct: usedPct, + resetsAt: epochMsToIso(model.end_time), + tone: toneForStatus(model.current_interval_status), + }); +} + +function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderUsageWindow | null { + const total = model.current_weekly_total_count ?? null; + const used = model.current_weekly_usage_count ?? null; + const remainingPercent = model.current_weekly_remaining_percent ?? null; + let usedPct: number | null = null; + if (typeof remainingPercent === "number" && Number.isFinite(remainingPercent)) { + usedPct = Math.max(0, Math.min(100, 100 - remainingPercent)); + } else if (typeof total === "number" && typeof used === "number") { + usedPct = computeUsedPct(total - used, total); + } + if (usedPct === null) return null; + return windowFromUsedPct({ + id: `weekly_${modelName}`, + label: `${modelName} · Weekly`, + utilizationPct: usedPct, + resetsAt: epochMsToIso(model.weekly_end_time), + tone: toneForStatus(model.current_weekly_status), + }); +} + +export class MiniMaxQuotaProvider implements ProviderUsageFetcher { + readonly providerId = "minimax"; + readonly displayName = "MiniMax"; + + private readonly logger: Logger; + private readonly fetchApi: ProviderApiFetch; + private readonly configPath: string; + private readonly credentialsPath: string; + private readonly env: NodeJS.ProcessEnv; + private readonly now: () => number; + + constructor(options: MiniMaxQuotaProviderOptions) { + this.logger = options.logger; + this.fetchApi = options.fetch ?? fetch; + this.configPath = options.configPath ?? MINIMAX_CONFIG_PATH; + this.credentialsPath = options.credentialsPath ?? MINIMAX_CREDENTIALS_PATH; + this.env = options.env ?? process.env; + this.now = options.now ?? Date.now; + } + + async fetchUsage(): Promise { + const auth = await this.resolveAuth(); + if (!auth) return unavailableUsage(this); + + const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, { + headers: { + Authorization: `Bearer ${auth.token}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + this.logger.debug({ status: res.status }, "MiniMax usage fetch failed"); + return unavailableUsage(this); + } + + const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); + const models = resp.model_remains ?? []; + + const windows: ProviderUsageWindow[] = []; + for (const model of models) { + const name = model.model_name ?? "token-plan"; + const intervalWindow = toIntervalWindow(name, model); + if (intervalWindow) windows.push(intervalWindow); + const weeklyWindow = toWeeklyWindow(name, model); + if (weeklyWindow) windows.push(weeklyWindow); + } + + return { + providerId: this.providerId, + displayName: this.displayName, + status: windows.length > 0 ? "available" : "unavailable", + planLabel: null, + windows, + balances: [], + details: [], + error: null, + }; + } + + private async resolveAuth(): Promise { + const envToken = this.env["MINIMAX_API_KEY"]; + if (envToken) { + const envBase = this.env["MINIMAX_BASE_URL"]; + return { + token: envToken, + baseUrl: resolveBaseUrl({ baseUrl: envBase }), + }; + } + + const credentials = await this.readCredentials(); + if (credentials?.access_token && !this.isExpired(credentials.expires_at)) { + return { + token: credentials.access_token, + baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }), + }; + } + + const config = await this.readConfig(); + if (config?.api_key) { + return { + token: config.api_key, + baseUrl: resolveBaseUrl({ + baseUrl: config.base_url, + region: config.region, + }), + }; + } + + if (config?.oauth?.access_token && !this.isExpired(config.oauth.expires_at)) { + return { + token: config.oauth.access_token, + baseUrl: resolveBaseUrl({ + baseUrl: config.oauth.resource_url ?? config.base_url, + region: config.region, + }), + }; + } + + return null; + } + + private isExpired(expiresAt: string | null | undefined): boolean { + if (!expiresAt) return false; + const parsed = Date.parse(expiresAt); + if (!Number.isFinite(parsed)) return false; + return parsed <= this.now(); + } + + private async readCredentials(): Promise | null> { + if (!existsSync(this.credentialsPath)) return null; + try { + const raw = JSON.parse(await fs.readFile(this.credentialsPath, "utf8")); + return MiniMaxCredentialsSchema.parse(raw); + } catch { + return null; + } + } + + private async readConfig(): Promise | null> { + if (!existsSync(this.configPath)) return null; + try { + const raw = JSON.parse(await fs.readFile(this.configPath, "utf8")); + return MiniMaxConfigSchema.parse(raw); + } catch { + return null; + } + } +} diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index b499926b4..5c23d4237 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -11,6 +11,7 @@ import { CopilotQuotaProvider } from "./providers/copilot.js"; import { CursorQuotaProvider } from "./providers/cursor.js"; import { GrokQuotaProvider } from "./providers/grok.js"; import { KimiQuotaProvider } from "./providers/kimi.js"; +import { MiniMaxQuotaProvider } from "./providers/minimax.js"; import { ZaiQuotaProvider } from "./providers/zai.js"; import { ProviderUsageService } from "./service.js"; @@ -50,6 +51,24 @@ function writeKimiCredentials(dir: string, accessToken: string): void { ); } +function writeMiniMaxConfig(dir: string, payload: Record): void { + mkdirSync(join(dir, ".mmx"), { recursive: true }); + writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); +} + +function writeMiniMaxCredentials( + dir: string, + accessToken: string, + expiresAt?: string, + resourceUrl?: string, +): void { + mkdirSync(join(dir, ".mmx"), { recursive: true }); + const payload: Record = { access_token: accessToken }; + if (expiresAt !== undefined) payload["expires_at"] = expiresAt; + if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl; + writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); +} + function makeClaudeResponse( overrides: Partial<{ five_hour: { utilization: number | string; resets_at: string }; @@ -315,6 +334,8 @@ describe("real provider usage fetchers", () => { "KIMI_API_KEY", "KIMI_CODE_HOME", "CODEX_HOME", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", ]) { delete process.env[key]; } @@ -339,6 +360,8 @@ describe("real provider usage fetchers", () => { platform?: typeof process.platform; keychain?: () => Promise; kimiHomeDir?: string; + miniMaxConfigPath?: string; + miniMaxCredentialsPath?: string; } = {}, ) { const logger = createLogger(); @@ -365,6 +388,13 @@ describe("real provider usage fetchers", () => { fetch: fetchThroughTestDouble, homeDir: options.kimiHomeDir, }), + new MiniMaxQuotaProvider({ + logger, + fetch: fetchThroughTestDouble, + configPath: options.miniMaxConfigPath ?? join(homeDir, ".mmx", "config.json"), + credentialsPath: + options.miniMaxCredentialsPath ?? join(homeDir, ".mmx", "credentials.json"), + }), ], cacheTtlMs: 0, }); @@ -750,4 +780,143 @@ describe("real provider usage fetchers", () => { ], }); }); + + it("fetches MiniMax usage from MINIMAX_API_KEY against the global endpoint", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + let requestedUrl: string | null = null; + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = url.toString(); + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_total_count: 1000, + current_interval_usage_count: 250, + current_interval_remaining_percent: 75, + current_weekly_total_count: 5000, + current_weekly_usage_count: 1200, + current_weekly_remaining_percent: 76, + }, + ], + }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); + expect(authorization).toBe("Bearer minimax_test_token"); + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ + id: "interval_MiniMax-M2.7", + label: "MiniMax-M2.7 · Interval", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-06-19T05:00:00.000Z", + }), + expect.objectContaining({ + id: "weekly_MiniMax-M2.7", + label: "MiniMax-M2.7 · Weekly", + usedPct: 24, + remainingPct: 76, + resetsAt: "2026-06-26T00:00:00.000Z", + }), + ]), + }); + }); + + it("returns unavailable MiniMax usage when no credentials are configured", async () => { + fetchApi = vi.fn() as never; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax.status).toBe("unavailable"); + expect(fetchApi).not.toHaveBeenCalled(); + }); + + it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { + writeMiniMaxCredentials( + homeDir, + "minimax_oauth_token", + "2030-01-01T00:00:00.000Z", + "https://account.example.com", + ); + let requestedUrl: string | null = null; + fetchApi = (async (url: RequestInfo | URL) => { + requestedUrl = url.toString(); + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + await service().listUsage(); + + expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); + }); + + it("falls back to MiniMax api_key in the CLI config file", async () => { + writeMiniMaxConfig(homeDir, { + api_key: "minimax_config_key", + region: "cn", + }); + let requestedUrl: string | null = null; + fetchApi = (async (url: RequestInfo | URL) => { + requestedUrl = url.toString(); + return jsonResponse({ model_remains: [] }); + }) as unknown as typeof fetch; + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); + expect(miniMax.status).toBe("unavailable"); + }); + + it("marks exhausted MiniMax interval windows with a danger tone", async () => { + process.env["MINIMAX_API_KEY"] = "minimax_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://api.minimax.io/v1/token_plan/remains", + () => + jsonResponse({ + model_remains: [ + { + model_name: "MiniMax-M2.7", + end_time: Date.parse("2026-06-19T05:00:00.000Z"), + weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), + current_interval_total_count: 100, + current_interval_usage_count: 100, + current_interval_remaining_percent: 0, + current_interval_status: 2, + current_weekly_total_count: 100, + current_weekly_usage_count: 10, + current_weekly_remaining_percent: 90, + current_weekly_status: 1, + }, + ], + }), + ], + ]), + ); + + const miniMax = findProvider(await service().listUsage(), "minimax"); + + expect(miniMax).toMatchObject({ + status: "available", + windows: expect.arrayContaining([ + expect.objectContaining({ + id: "interval_MiniMax-M2.7", + usedPct: 100, + tone: "danger", + }), + expect.objectContaining({ + id: "weekly_MiniMax-M2.7", + tone: "ok", + }), + ]), + }); + }); });