fix(dashboard): show missing Minimax usage rows and weekly windows
The Minimax usage panel only rendered one model row. The primary `general` model meters quota purely via `current_interval_remaining_percent` (its `current_interval_total_count` is 0), so the count-based `total > 0` visibility filter dropped it entirely. The percent was also derived from count fields rather than the authoritative `*_remaining_percent` field. fetchMinimaxUsage now builds windows via a helper that prefers `*_remaining_percent` (count-based fallback when absent) and skips a window only when no quota signal exists. Each model's separate weekly quota window is now surfaced as its own indicator alongside the interval window. Verified against the live coding_plan/remains endpoint: 2 models (general, video) now produce 4 windows (interval + weekly each) instead of 1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/minimax-usage-missing-rows.md
Normal file
7
.changeset/minimax-usage-missing-rows.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix missing model rows in the Minimax provider usage panel. The primary `general` model meters quota purely via `current_interval_remaining_percent` (its count fields are `0`), so the previous count-based visibility filter dropped it entirely.
|
||||||
|
|
||||||
|
Minimax usage now prefers the authoritative `*_remaining_percent` field (with a count-based fallback) and renders a window only when a model exposes any quota signal. Each model's separate weekly quota window (`current_weekly_remaining_percent`, `weekly_*` timing) is now surfaced as its own indicator alongside the interval window.
|
||||||
@@ -2607,6 +2607,95 @@ describe("usage", () => {
|
|||||||
expect(speechWindow).toBeDefined();
|
expect(speechWindow).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows percent-only models and weekly windows from real coding_plan response", async () => {
|
||||||
|
// Mirrors a real coding_plan/remains response: the primary "general"
|
||||||
|
// model meters quota purely via *_remaining_percent (its count fields are
|
||||||
|
// 0), and every model also carries a separate weekly quota window.
|
||||||
|
const now = Date.now();
|
||||||
|
const mockResponse = {
|
||||||
|
model_remains: [
|
||||||
|
{
|
||||||
|
model_name: "general",
|
||||||
|
current_interval_total_count: 0,
|
||||||
|
current_interval_usage_count: 0,
|
||||||
|
current_interval_remaining_percent: 91,
|
||||||
|
remains_time: 2_039_915,
|
||||||
|
start_time: now - 3 * 60 * 60 * 1000,
|
||||||
|
end_time: now + 2 * 60 * 60 * 1000,
|
||||||
|
current_weekly_total_count: 0,
|
||||||
|
current_weekly_usage_count: 0,
|
||||||
|
current_weekly_remaining_percent: 100,
|
||||||
|
weekly_remains_time: 380_039_915,
|
||||||
|
weekly_start_time: now - 1 * 60 * 60 * 1000,
|
||||||
|
weekly_end_time: now + 6 * 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model_name: "video",
|
||||||
|
current_interval_total_count: 3,
|
||||||
|
current_interval_usage_count: 3,
|
||||||
|
current_interval_remaining_percent: 100,
|
||||||
|
remains_time: 34_439_915,
|
||||||
|
start_time: now - 1 * 60 * 60 * 1000,
|
||||||
|
end_time: now + 23 * 60 * 60 * 1000,
|
||||||
|
current_weekly_total_count: 21,
|
||||||
|
current_weekly_usage_count: 21,
|
||||||
|
current_weekly_remaining_percent: 100,
|
||||||
|
weekly_remains_time: 380_039_915,
|
||||||
|
weekly_start_time: now - 1 * 60 * 60 * 1000,
|
||||||
|
weekly_end_time: now + 6 * 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockReadFile.mockImplementation((filePath: string) => {
|
||||||
|
if (filePath.includes(".pi/agent/auth.json")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
minimax: { type: "api_key", key: "test-api-key" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("File not found"));
|
||||||
|
});
|
||||||
|
mockExecFileSync.mockImplementation(() => {
|
||||||
|
throw new Error("Keychain item not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||||
|
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||||
|
if (event === "end") handler();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
callback(mockRes);
|
||||||
|
return mockReq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const minimax = providers.find((p) => p.name === "Minimax")!;
|
||||||
|
|
||||||
|
expect(minimax.status).toBe("ok");
|
||||||
|
// 2 models × (interval + weekly) = 4 windows
|
||||||
|
expect(minimax.windows).toHaveLength(4);
|
||||||
|
|
||||||
|
// "general" interval row must appear even though its count fields are 0 —
|
||||||
|
// remaining-percent is the source of truth.
|
||||||
|
const generalInterval = minimax.windows.find((w) => w.label === "general")!;
|
||||||
|
expect(generalInterval).toBeDefined();
|
||||||
|
expect(generalInterval.percentLeft).toBeCloseTo(91, 0);
|
||||||
|
expect(generalInterval.percentUsed).toBeCloseTo(9, 0);
|
||||||
|
|
||||||
|
// Weekly window is surfaced as its own indicator.
|
||||||
|
const generalWeekly = minimax.windows.find((w) => w.label === "general (weekly)")!;
|
||||||
|
expect(generalWeekly).toBeDefined();
|
||||||
|
expect(generalWeekly.percentLeft).toBeCloseTo(100, 0);
|
||||||
|
|
||||||
|
expect(minimax.windows.find((w) => w.label === "video")).toBeDefined();
|
||||||
|
expect(minimax.windows.find((w) => w.label === "video (weekly)")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips models with zero quota", async () => {
|
it("skips models with zero quota", async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
model_remains: [
|
model_remains: [
|
||||||
|
|||||||
@@ -1458,50 +1458,89 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise<Provide
|
|||||||
const data = JSON.parse(res.body);
|
const data = JSON.parse(res.body);
|
||||||
usage.status = "ok";
|
usage.status = "ok";
|
||||||
|
|
||||||
// Parse model_remains array — group by model family
|
// Each model in model_remains can expose two quota windows: a rolling
|
||||||
|
// interval window and a weekly window. Minimax reports remaining quota two
|
||||||
|
// ways — an authoritative *_remaining_percent field, and count fields that
|
||||||
|
// are frequently 0 for percent-metered models (e.g. the primary "general"
|
||||||
|
// model). Prefer the percent field; fall back to counts only when it's
|
||||||
|
// absent. Keying solely off the count field hides whole model rows.
|
||||||
|
const buildWindow = (
|
||||||
|
label: string,
|
||||||
|
totalCount: number,
|
||||||
|
// Note: Minimax's *_usage_count is actually REMAINING, not used
|
||||||
|
// (known API quirk per https://github.com/MiniMax-AI/MiniMax-M2/issues/99)
|
||||||
|
remainingCount: number,
|
||||||
|
remainingPercent: number | undefined,
|
||||||
|
remainsTime: number | undefined,
|
||||||
|
startTime: number | undefined,
|
||||||
|
endTime: number | undefined,
|
||||||
|
): UsageWindow | null => {
|
||||||
|
let percentLeft: number;
|
||||||
|
if (typeof remainingPercent === "number" && Number.isFinite(remainingPercent)) {
|
||||||
|
percentLeft = remainingPercent;
|
||||||
|
} else if (totalCount > 0) {
|
||||||
|
const used = Math.max(0, totalCount - remainingCount);
|
||||||
|
percentLeft = 100 - (used / totalCount) * 100;
|
||||||
|
} else {
|
||||||
|
// No quota signal at all — skip (unused model type / window).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
percentLeft = Math.min(100, Math.max(0, percentLeft));
|
||||||
|
const percentUsed = Math.min(100, Math.max(0, 100 - percentLeft));
|
||||||
|
|
||||||
|
let resetText: string | null = null;
|
||||||
|
let resetMs: number | undefined;
|
||||||
|
let resetAt: string | undefined;
|
||||||
|
if (remainsTime && remainsTime > 0) {
|
||||||
|
resetMs = remainsTime;
|
||||||
|
resetText = `resets in ${formatDuration(remainsTime)}`;
|
||||||
|
resetAt = new Date(Date.now() + remainsTime).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let windowDurationMs: number | undefined;
|
||||||
|
if (startTime && endTime) {
|
||||||
|
windowDurationMs = endTime - startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
percentUsed,
|
||||||
|
percentLeft,
|
||||||
|
resetText,
|
||||||
|
resetMs,
|
||||||
|
resetAt,
|
||||||
|
windowDurationMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response
|
||||||
const modelRemains: any[] = data?.model_remains || [];
|
const modelRemains: any[] = data?.model_remains || [];
|
||||||
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
|
if (Array.isArray(modelRemains)) {
|
||||||
for (const model of modelRemains) {
|
for (const model of modelRemains) {
|
||||||
const modelName: string = model.model_name || "Unknown";
|
const modelName: string = model.model_name || "Unknown";
|
||||||
const total: number = model.current_interval_total_count ?? 0;
|
|
||||||
// Note: Minimax's current_interval_usage_count is actually REMAINING, not used
|
|
||||||
// (known API quirk per https://github.com/MiniMax-AI/MiniMax-M2/issues/99)
|
|
||||||
const remaining: number = model.current_interval_usage_count ?? 0;
|
|
||||||
const used: number = Math.max(0, total - remaining);
|
|
||||||
|
|
||||||
const percentUsed = total > 0 ? (used / total) * 100 : 0;
|
const interval = buildWindow(
|
||||||
|
modelName,
|
||||||
|
model.current_interval_total_count ?? 0,
|
||||||
|
model.current_interval_usage_count ?? 0,
|
||||||
|
model.current_interval_remaining_percent,
|
||||||
|
model.remains_time,
|
||||||
|
model.start_time,
|
||||||
|
model.end_time,
|
||||||
|
);
|
||||||
|
if (interval) usage.windows.push(interval);
|
||||||
|
|
||||||
let resetText: string | null = null;
|
const weekly = buildWindow(
|
||||||
let resetMs: number | undefined;
|
`${modelName} (weekly)`,
|
||||||
let windowDurationMs: number | undefined;
|
model.current_weekly_total_count ?? 0,
|
||||||
|
model.current_weekly_usage_count ?? 0,
|
||||||
const remainsTime: number = model.remains_time;
|
model.current_weekly_remaining_percent,
|
||||||
let resetAt: string | undefined;
|
model.weekly_remains_time,
|
||||||
if (remainsTime && remainsTime > 0) {
|
model.weekly_start_time,
|
||||||
resetMs = remainsTime;
|
model.weekly_end_time,
|
||||||
resetText = `resets in ${formatDuration(remainsTime)}`;
|
);
|
||||||
resetAt = new Date(Date.now() + remainsTime).toISOString();
|
if (weekly) usage.windows.push(weekly);
|
||||||
}
|
|
||||||
|
|
||||||
const startTime: number = model.start_time;
|
|
||||||
const endTime: number = model.end_time;
|
|
||||||
if (startTime && endTime) {
|
|
||||||
windowDurationMs = endTime - startTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show models that have a quota > 0 (skip unused model types)
|
|
||||||
if (total > 0) {
|
|
||||||
usage.windows.push({
|
|
||||||
label: modelName,
|
|
||||||
percentUsed: Math.min(100, Math.max(0, percentUsed)),
|
|
||||||
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
|
|
||||||
resetText,
|
|
||||||
resetMs,
|
|
||||||
resetAt,
|
|
||||||
windowDurationMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user