Skip to content
Closed
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
74 changes: 49 additions & 25 deletions src/providers/minimax-coding-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ import {
resolveMiniMaxChinaAuthCached,
type ResolvedMiniMaxAuth,
} from "../lib/minimax-auth.js";
import {
getMiniMaxQuotaEndpoint,
type MiniMaxQuotaEndpointId,
} from "../lib/minimax-endpoints.js";
import { getMiniMaxQuotaEndpoint, type MiniMaxQuotaEndpointId } from "../lib/minimax-endpoints.js";
import { sanitizeDisplayText } from "../lib/display-sanitize.js";
import { fetchWithTimeout } from "../lib/http.js";
import {
Expand All @@ -36,14 +33,19 @@ const USER_AGENT = "OpenCode-Quota-Toast/1.0";

interface MiniMaxModelRemain {
model_name: string;
current_interval_total_count: number;
current_interval_total_count?: number;
/** Endpoint-specific raw count: international reports remaining, China reports used. */
current_interval_usage_count: number;
current_interval_usage_count?: number;
remains_time: number;
current_weekly_total_count?: number;
/** Endpoint-specific raw count: international reports remaining, China reports used. */
current_weekly_usage_count?: number;
weekly_remains_time?: number;
/** Percent remaining for the 5h window (0-100). Reported by some accounts when counts are zeroed. */
current_interval_remaining_percent?: number;
current_interval_status?: number;
current_weekly_remaining_percent?: number;
current_weekly_status?: number;
}

interface MiniMaxApiResponse {
Expand All @@ -68,6 +70,7 @@ interface MiniMaxWindowSpec {
getTotal(model: MiniMaxModelRemain): number | undefined;
getCount(model: MiniMaxModelRemain): number | undefined;
getResetOffsetMs(model: MiniMaxModelRemain): number | undefined;
getPercentRemaining(model: MiniMaxModelRemain): number | undefined;
}

const MINIMAX_WINDOW_SPECS: readonly MiniMaxWindowSpec[] = [
Expand All @@ -78,6 +81,7 @@ const MINIMAX_WINDOW_SPECS: readonly MiniMaxWindowSpec[] = [
getTotal: (model) => model.current_interval_total_count,
getCount: (model) => model.current_interval_usage_count,
getResetOffsetMs: (model) => model.remains_time,
getPercentRemaining: (model) => model.current_interval_remaining_percent,
},
{
window: "weekly",
Expand All @@ -86,6 +90,7 @@ const MINIMAX_WINDOW_SPECS: readonly MiniMaxWindowSpec[] = [
getTotal: (model) => model.current_weekly_total_count,
getCount: (model) => model.current_weekly_usage_count,
getResetOffsetMs: (model) => model.weekly_remains_time,
getPercentRemaining: (model) => model.current_weekly_remaining_percent,
},
];

Expand All @@ -96,18 +101,21 @@ function isFiniteNumber(value: unknown): value is number {
/**
* Type guard that validates a value is a well-formed MiniMax model record.
*
* Checks for `model_name` (string) and the 5-hour/request quota numeric fields
* to prevent `NaN` arithmetic when the API response shape is unexpected.
* Accepts either a count-based record (with `current_interval_total_count` and
* `current_interval_usage_count`) or a percent-based record (with
* `current_interval_remaining_percent`). `remains_time` is always required so
* the entry has a valid reset offset.
*/
function isMiniMaxModelRecord(value: unknown): value is MiniMaxModelRemain {
if (value === null || typeof value !== "object" || !("model_name" in value)) return false;
const v = value as Record<string, unknown>;
return (
typeof v.model_name === "string" &&
if (typeof v.model_name !== "string" || !isFiniteNumber(v.remains_time)) return false;

const hasCounts =
isFiniteNumber(v.current_interval_total_count) &&
isFiniteNumber(v.current_interval_usage_count) &&
isFiniteNumber(v.remains_time)
);
isFiniteNumber(v.current_interval_usage_count);
const hasPercent = isFiniteNumber(v.current_interval_remaining_percent);
return hasCounts || hasPercent;
}

function roundPercent(value: number): number {
Expand Down Expand Up @@ -158,19 +166,33 @@ function buildMiniMaxEntry(
const total = spec.getTotal(model);
const rawCount = spec.getCount(model);
const resetOffsetMs = spec.getResetOffsetMs(model);
if (!isFiniteNumber(total) || !isFiniteNumber(rawCount) || !isFiniteNumber(resetOffsetMs)) {
return null;
if (!isFiniteNumber(resetOffsetMs)) return null;

if (isFiniteNumber(total) && isFiniteNumber(rawCount) && total > 0) {
const { used, remaining } = normalizeMiniMaxCounts(total, rawCount, countSemantics);
const percentRemaining = roundPercent((remaining / total) * 100);

return {
window: spec.window,
name: spec.name.replace(MINIMAX_PROVIDER_LABEL, providerLabel),
group: providerLabel,
label: spec.label,
right: `${used}/${total}`,
percentRemaining,
resetTimeIso: new Date(Date.now() + Math.max(0, resetOffsetMs)).toISOString(),
};
}
if (total <= 0) return null;
const { used, remaining } = normalizeMiniMaxCounts(total, rawCount, countSemantics);
const percentRemaining = roundPercent((remaining / total) * 100);

const percentRaw = spec.getPercentRemaining(model);
if (!isFiniteNumber(percentRaw)) return null;
const percentRemaining = roundPercent(percentRaw);

return {
window: spec.window,
name: spec.name.replace(MINIMAX_PROVIDER_LABEL, providerLabel),
group: providerLabel,
label: spec.label,
right: `${used}/${total}`,
right: `${100 - percentRemaining}%`,
percentRemaining,
resetTimeIso: new Date(Date.now() + Math.max(0, resetOffsetMs)).toISOString(),
};
Expand Down Expand Up @@ -206,12 +228,14 @@ function selectCanonicalMiniMaxModel(
return wildcardModel;
}

return [...models].sort((left, right) => {
const percentDiff =
getWorstPercent(left, countSemantics) - getWorstPercent(right, countSemantics);
if (percentDiff !== 0) return percentDiff;
return left.model_name.localeCompare(right.model_name);
})[0] ?? null;
return (
[...models].sort((left, right) => {
const percentDiff =
getWorstPercent(left, countSemantics) - getWorstPercent(right, countSemantics);
if (percentDiff !== 0) return percentDiff;
return left.model_name.localeCompare(right.model_name);
})[0] ?? null
);
}

/**
Expand Down
140 changes: 134 additions & 6 deletions tests/providers.minimax-coding-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ function createCodingPlanModel(
current_weekly_total_count: number;
current_weekly_usage_count: number;
weekly_remains_time: number;
current_interval_remaining_percent: number;
current_interval_status: number;
current_weekly_remaining_percent: number;
current_weekly_status: number;
}> = {},
) {
return {
Expand All @@ -70,12 +74,19 @@ function mockMiniMaxAuthInvalid(error = "Invalid API key") {
mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ state: "invalid", error });
}

function mockMiniMaxAuthConfigured(apiKey = "test-key", endpoint: "international" | "china" = "international") {
function mockMiniMaxAuthConfigured(
apiKey = "test-key",
endpoint: "international" | "china" = "international",
) {
mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ state: "configured", apiKey, endpoint });
}

function mockMiniMaxChinaAuthConfigured(apiKey = "china-key") {
mocks.resolveMiniMaxChinaAuthCached.mockResolvedValueOnce({ state: "configured", apiKey, endpoint: "china" });
mocks.resolveMiniMaxChinaAuthCached.mockResolvedValueOnce({
state: "configured",
apiKey,
endpoint: "china",
});
}

function mockMiniMaxHttpSuccess(models: unknown[]) {
Expand Down Expand Up @@ -303,6 +314,114 @@ describe("minimax-coding-plan provider", () => {
});
});

it("falls back to remaining_percent when international generic rows report zero counts", async () => {
mockMiniMaxAuthConfigured();
mockMiniMaxHttpSuccess([
createCodingPlanModel({
model_name: "general",
current_interval_total_count: 0,
current_interval_usage_count: 0,
current_weekly_total_count: 0,
current_weekly_usage_count: 0,
weekly_remains_time: 27_563_753,
current_interval_remaining_percent: 86,
current_interval_status: 1,
current_weekly_remaining_percent: 90,
current_weekly_status: 1,
}),
createCodingPlanModel({
model_name: "video",
current_interval_total_count: 0,
current_interval_usage_count: 0,
current_weekly_total_count: 0,
current_weekly_usage_count: 0,
weekly_remains_time: 27_563_753,
current_interval_remaining_percent: 100,
current_interval_status: 3,
current_weekly_remaining_percent: 100,
current_weekly_status: 3,
}),
]);

const out = await runProviderFetch();

expectAttemptedWithNoErrors(out);
expect(out.entries).toHaveLength(2);
expect(out.entries[0]).toMatchObject({
window: "five_hour",
name: "MiniMax Coding Plan 5h",
group: "MiniMax Coding Plan",
label: "5h:",
right: "14%",
percentRemaining: 86,
});
expect(out.entries[1]).toMatchObject({
window: "weekly",
name: "MiniMax Coding Plan Weekly",
group: "MiniMax Coding Plan",
label: "Weekly:",
right: "10%",
percentRemaining: 90,
});
});

it("uses remaining_percent when count fields are missing entirely from the API response", async () => {
mockMiniMaxAuthConfigured();
mockMiniMaxHttpSuccess([
{
model_name: "general",
remains_time: 13_163_753,
current_interval_remaining_percent: 75,
current_weekly_remaining_percent: 80,
weekly_remains_time: 27_563_753,
},
]);

const out = await runProviderFetch();

expectAttemptedWithNoErrors(out);
expect(out.entries).toHaveLength(2);
expect(out.entries[0]).toMatchObject({
window: "five_hour",
right: "25%",
percentRemaining: 75,
});
expect(out.entries[1]).toMatchObject({
window: "weekly",
right: "20%",
percentRemaining: 80,
});
});

it("prefers the count path when both count and percent fields are populated", async () => {
mockMiniMaxAuthConfigured();
mockMiniMaxHttpSuccess([
createCodingPlanModel({
model_name: "MiniMax-M2.7",
current_interval_total_count: 100,
current_interval_usage_count: 25,
current_weekly_total_count: 1000,
current_weekly_usage_count: 250,
current_interval_remaining_percent: 12,
current_weekly_remaining_percent: 13,
}),
]);

const out = await runProviderFetch();

expectAttemptedWithNoErrors(out);
expect(out.entries[0]).toMatchObject({
window: "five_hour",
right: "75/100",
percentRemaining: 25,
});
expect(out.entries[1]).toMatchObject({
window: "weekly",
right: "750/1000",
percentRemaining: 25,
});
});

it("selects the lowest-remaining international generic row", async () => {
mockMiniMaxAuthConfigured();
mockMiniMaxHttpSuccess([
Expand Down Expand Up @@ -523,7 +642,9 @@ describe("minimax-coding-plan provider", () => {

const statusOut = await minimaxCodingPlanProvider.fetch({ config: {} } as any);
expectAttemptedWithErrorLabel(statusOut, "MiniMax Coding Plan");
expect(statusOut.errors[0]?.message).toBe(`MiniMax API error: ${`${"x".repeat(140)} retry`.slice(0, 120)}`);
expect(statusOut.errors[0]?.message).toBe(
`MiniMax API error: ${`${"x".repeat(140)} retry`.slice(0, 120)}`,
);

mockMiniMaxAuthConfigured();
mocks.fetchWithTimeout.mockRejectedValueOnce(new Error("network\nfailed"));
Expand Down Expand Up @@ -590,15 +711,22 @@ describe("minimax-coding-plan provider", () => {
mocks.isCanonicalProviderAvailable.mockResolvedValueOnce(true);
mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce(authState);

const available = await minimaxCodingPlanProvider.isAvailable({ config: { enabledProviders: "auto" } } as any);
const available = await minimaxCodingPlanProvider.isAvailable({
config: { enabledProviders: "auto" },
} as any);
expect(available).toBe(expected);
});

it("returns false when auth exists but the minimax provider is not configured", async () => {
mocks.isCanonicalProviderAvailable.mockResolvedValueOnce(false);
mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({ state: "configured", apiKey: "test-key" });
mocks.resolveMiniMaxAuthCached.mockResolvedValueOnce({
state: "configured",
apiKey: "test-key",
});

const available = await minimaxCodingPlanProvider.isAvailable({ config: { enabledProviders: "auto" } } as any);
const available = await minimaxCodingPlanProvider.isAvailable({
config: { enabledProviders: "auto" },
} as any);
expect(available).toBe(false);
expect(mocks.resolveMiniMaxAuthCached).not.toHaveBeenCalled();
});
Expand Down