Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/app/src/components/icons/minimax-icon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color} fillRule="evenodd">
<Path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" />
</Svg>
);
}
1 change: 1 addition & 0 deletions packages/app/src/components/provider-icon-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/provider-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +23,7 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
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,
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/provider-icon-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [
"codex",
"copilot",
"kiro",
"minimax",
"omp",
"opencode",
"pi",
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/services/quota-fetcher/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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(
Expand Down
275 changes: 275 additions & 0 deletions packages/server/src/services/quota-fetcher/providers/minimax.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MiniMaxModelRemainSchema>;

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<ProviderUsage> {
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<MiniMaxResolvedAuth | null> {
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<z.infer<typeof MiniMaxCredentialsSchema> | 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<z.infer<typeof MiniMaxConfigSchema> | 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;
}
}
}
Loading
Loading