Merge pull request #1347 from Runfusion/gsxdsm/activitydebug
fix(dashboard): show missing Minimax usage rows and weekly windows
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const mockResponse = {
|
||||
model_remains: [
|
||||
|
||||
@@ -1458,50 +1458,89 @@ async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise<Provide
|
||||
const data = JSON.parse(res.body);
|
||||
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
|
||||
const modelRemains: any[] = data?.model_remains || [];
|
||||
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
|
||||
if (Array.isArray(modelRemains)) {
|
||||
for (const model of modelRemains) {
|
||||
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;
|
||||
let resetMs: number | undefined;
|
||||
let windowDurationMs: number | undefined;
|
||||
|
||||
const remainsTime: number = model.remains_time;
|
||||
let resetAt: string | undefined;
|
||||
if (remainsTime && remainsTime > 0) {
|
||||
resetMs = remainsTime;
|
||||
resetText = `resets in ${formatDuration(remainsTime)}`;
|
||||
resetAt = new Date(Date.now() + remainsTime).toISOString();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
const weekly = buildWindow(
|
||||
`${modelName} (weekly)`,
|
||||
model.current_weekly_total_count ?? 0,
|
||||
model.current_weekly_usage_count ?? 0,
|
||||
model.current_weekly_remaining_percent,
|
||||
model.weekly_remains_time,
|
||||
model.weekly_start_time,
|
||||
model.weekly_end_time,
|
||||
);
|
||||
if (weekly) usage.windows.push(weekly);
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user