feat(KB-152): add Minimax and Zai providers with usage pace indicators
- Add Minimax provider backend with full test coverage - Add Zai (Zhipu AI) provider backend for additional model options - Implement pace calculation in UsageWindow with ahead/behind/on-pace detection - Update UsageIndicator frontend component to consume backend pace data - Fix pace indicator colors and icons to match design specification - Resolve ListView QuickEntryBox type error for type safety
This commit is contained in:
5
.changeset/add-usage-pace-and-providers.md
Normal file
5
.changeset/add-usage-pace-and-providers.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add weekly pace indicators to usage dropdown and support Minimax and Zai AI providers.
|
||||||
@@ -192,6 +192,13 @@ export function fetchModels(): Promise<ModelInfo[]> {
|
|||||||
|
|
||||||
// --- Usage API ---
|
// --- Usage API ---
|
||||||
|
|
||||||
|
/** Pace information for weekly usage windows */
|
||||||
|
export interface UsagePace {
|
||||||
|
status: "ahead" | "on-track" | "behind";
|
||||||
|
percentElapsed: number; // 0-100, how much of the window time has passed
|
||||||
|
message: string; // e.g., "Using 15% over your limit pace"
|
||||||
|
}
|
||||||
|
|
||||||
/** Usage window for a provider (e.g., "Session (5h)", "Weekly") */
|
/** Usage window for a provider (e.g., "Session (5h)", "Weekly") */
|
||||||
export interface UsageWindow {
|
export interface UsageWindow {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -200,6 +207,7 @@ export interface UsageWindow {
|
|||||||
resetText: string | null; // e.g., "resets in 2h"
|
resetText: string | null; // e.g., "resets in 2h"
|
||||||
resetMs?: number; // ms until reset
|
resetMs?: number; // ms until reset
|
||||||
windowDurationMs?: number; // total window length
|
windowDurationMs?: number; // total window length
|
||||||
|
pace?: UsagePace; // pace indicator for weekly windows
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Provider usage data */
|
/** Provider usage data */
|
||||||
|
|||||||
@@ -388,7 +388,10 @@ export function ListView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-quick-entry">
|
<div className="list-quick-entry">
|
||||||
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
|
<QuickEntryBox
|
||||||
|
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-column-toggle" ref={columnDropdownRef}>
|
<div className="list-column-toggle" ref={columnDropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -586,6 +586,11 @@ describe("UsageIndicator", () => {
|
|||||||
resetText: "resets in 3d",
|
resetText: "resets in 3d",
|
||||||
resetMs: 259200000, // 3 days remaining
|
resetMs: 259200000, // 3 days remaining
|
||||||
windowDurationMs: 604800000, // 7 days total
|
windowDurationMs: 604800000, // 7 days total
|
||||||
|
pace: {
|
||||||
|
status: "behind",
|
||||||
|
percentElapsed: 57,
|
||||||
|
message: "Using 27% under pace",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -641,7 +646,7 @@ describe("UsageIndicator", () => {
|
|||||||
expect(paceMarkers.length).toBe(0);
|
expect(paceMarkers.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render pace marker when resetMs or windowDurationMs is undefined", () => {
|
it("does not render pace marker when pace is undefined", () => {
|
||||||
mockUseUsageData.mockReturnValue({
|
mockUseUsageData.mockReturnValue({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -654,7 +659,7 @@ describe("UsageIndicator", () => {
|
|||||||
percentUsed: 30,
|
percentUsed: 30,
|
||||||
percentLeft: 70,
|
percentLeft: 70,
|
||||||
resetText: "resets in 3d",
|
resetText: "resets in 3d",
|
||||||
// No resetMs or windowDurationMs
|
// No pace field
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -686,6 +691,11 @@ describe("UsageIndicator", () => {
|
|||||||
resetText: "resets in 3.5d",
|
resetText: "resets in 3.5d",
|
||||||
resetMs: 302400000, // 3.5 days remaining out of 7
|
resetMs: 302400000, // 3.5 days remaining out of 7
|
||||||
windowDurationMs: 604800000, // 7 days total
|
windowDurationMs: 604800000, // 7 days total
|
||||||
|
pace: {
|
||||||
|
status: "ahead",
|
||||||
|
percentElapsed: 50,
|
||||||
|
message: "Using 20% over pace",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -698,10 +708,8 @@ describe("UsageIndicator", () => {
|
|||||||
|
|
||||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
// percentElapsed = 100 - (302400 / 604800 * 100) = 100 - 50 = 50%
|
|
||||||
// paceDelta = 70 - 50 = 20% (ahead)
|
|
||||||
const paceRow = screen.getByTestId("pace-row");
|
const paceRow = screen.getByTestId("pace-row");
|
||||||
expect(paceRow).toHaveTextContent(/ahead of pace/);
|
expect(paceRow).toHaveTextContent(/over pace/);
|
||||||
expect(paceRow).toHaveTextContent("20%");
|
expect(paceRow).toHaveTextContent("20%");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -720,6 +728,11 @@ describe("UsageIndicator", () => {
|
|||||||
resetText: "resets in 3.5d",
|
resetText: "resets in 3.5d",
|
||||||
resetMs: 302400000, // 3.5 days remaining out of 7
|
resetMs: 302400000, // 3.5 days remaining out of 7
|
||||||
windowDurationMs: 604800000, // 7 days total
|
windowDurationMs: 604800000, // 7 days total
|
||||||
|
pace: {
|
||||||
|
status: "behind",
|
||||||
|
percentElapsed: 50,
|
||||||
|
message: "Using 30% under pace",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -732,10 +745,8 @@ describe("UsageIndicator", () => {
|
|||||||
|
|
||||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
// percentElapsed = 100 - (302400 / 604800 * 100) = 100 - 50 = 50%
|
|
||||||
// paceDelta = 20 - 50 = -30% (behind)
|
|
||||||
const paceRow = screen.getByTestId("pace-row");
|
const paceRow = screen.getByTestId("pace-row");
|
||||||
expect(paceRow).toHaveTextContent(/behind pace/);
|
expect(paceRow).toHaveTextContent(/under pace/);
|
||||||
expect(paceRow).toHaveTextContent("30%");
|
expect(paceRow).toHaveTextContent("30%");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -754,6 +765,11 @@ describe("UsageIndicator", () => {
|
|||||||
resetText: "resets in 3.5d",
|
resetText: "resets in 3.5d",
|
||||||
resetMs: 302400000, // 3.5 days remaining out of 7
|
resetMs: 302400000, // 3.5 days remaining out of 7
|
||||||
windowDurationMs: 604800000, // 7 days total
|
windowDurationMs: 604800000, // 7 days total
|
||||||
|
pace: {
|
||||||
|
status: "on-track",
|
||||||
|
percentElapsed: 50,
|
||||||
|
message: "On pace with time elapsed",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -766,8 +782,6 @@ describe("UsageIndicator", () => {
|
|||||||
|
|
||||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
// percentElapsed = 100 - (302400 / 604800 * 100) = 100 - 50 = 50%
|
|
||||||
// paceDelta = 52 - 50 = 2% (within 5% threshold, on pace)
|
|
||||||
const paceRow = screen.getByTestId("pace-row");
|
const paceRow = screen.getByTestId("pace-row");
|
||||||
expect(paceRow).toHaveTextContent(/On pace/);
|
expect(paceRow).toHaveTextContent(/On pace/);
|
||||||
});
|
});
|
||||||
@@ -788,6 +802,11 @@ describe("UsageIndicator", () => {
|
|||||||
resetText: "resets in 3.5d",
|
resetText: "resets in 3.5d",
|
||||||
resetMs: 302400000, // 50% elapsed
|
resetMs: 302400000, // 50% elapsed
|
||||||
windowDurationMs: 604800000,
|
windowDurationMs: 604800000,
|
||||||
|
pace: {
|
||||||
|
status: "behind",
|
||||||
|
percentElapsed: 50,
|
||||||
|
message: "Using 20% under pace",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -814,7 +833,7 @@ describe("UsageIndicator", () => {
|
|||||||
expect(paceMarker.style.left).toBe("50%");
|
expect(paceMarker.style.left).toBe("50%");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pace percentage text inverts correctly when switching to remaining mode", () => {
|
it("pace percentage text uses backend message directly", () => {
|
||||||
// Clear localStorage to ensure fresh 'used' mode
|
// Clear localStorage to ensure fresh 'used' mode
|
||||||
localStorage.removeItem("kb-usage-view-mode");
|
localStorage.removeItem("kb-usage-view-mode");
|
||||||
|
|
||||||
@@ -833,6 +852,11 @@ describe("UsageIndicator", () => {
|
|||||||
resetText: "resets in 3.5d",
|
resetText: "resets in 3.5d",
|
||||||
resetMs: 302400000, // 50% elapsed
|
resetMs: 302400000, // 50% elapsed
|
||||||
windowDurationMs: 604800000,
|
windowDurationMs: 604800000,
|
||||||
|
pace: {
|
||||||
|
status: "ahead",
|
||||||
|
percentElapsed: 50,
|
||||||
|
message: "Using 20% over pace",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -847,16 +871,14 @@ describe("UsageIndicator", () => {
|
|||||||
|
|
||||||
// In used mode: ahead of pace (70% used vs 50% elapsed)
|
// In used mode: ahead of pace (70% used vs 50% elapsed)
|
||||||
let paceRow = screen.getByTestId("pace-row");
|
let paceRow = screen.getByTestId("pace-row");
|
||||||
expect(paceRow).toHaveTextContent(/ahead of pace/);
|
expect(paceRow).toHaveTextContent(/over pace/);
|
||||||
expect(paceRow).toHaveTextContent("⚡");
|
|
||||||
|
|
||||||
// Switch to remaining mode
|
// Switch to remaining mode - message stays the same (from backend)
|
||||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||||
fireEvent.click(remainingBtn);
|
fireEvent.click(remainingBtn);
|
||||||
|
|
||||||
// In remaining mode: message should invert (behind on remaining)
|
// The message comes from backend, so it doesn't change based on view mode
|
||||||
// When ahead on usage (using more than expected), you're behind on remaining
|
|
||||||
paceRow = screen.getByTestId("pace-row");
|
paceRow = screen.getByTestId("pace-row");
|
||||||
expect(paceRow).toHaveTextContent(/behind on remaining/);
|
expect(paceRow).toHaveTextContent("Using 20% over pace");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { X, RefreshCw, Activity } from "lucide-react";
|
import { X, RefreshCw, Activity, TrendingUp, CheckCircle, Info } from "lucide-react";
|
||||||
import type { ProviderUsage, UsageWindow } from "../api";
|
import type { ProviderUsage, UsageWindow } from "../api";
|
||||||
import { useUsageData } from "../hooks/useUsageData";
|
import { useUsageData } from "../hooks/useUsageData";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
@@ -38,31 +38,20 @@ function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
|
|||||||
const headerText = isRemainingMode ? `${window.percentLeft}% remaining` : `${window.percentUsed}% used`;
|
const headerText = isRemainingMode ? `${window.percentLeft}% remaining` : `${window.percentUsed}% used`;
|
||||||
const footerText = isRemainingMode ? `${window.percentUsed}% used` : `${window.percentLeft}% left`;
|
const footerText = isRemainingMode ? `${window.percentUsed}% used` : `${window.percentLeft}% left`;
|
||||||
|
|
||||||
// Pace calculation for weekly windows
|
// Use pace from backend if available (for weekly windows)
|
||||||
const shouldShowPace = window.label.toLowerCase().includes('weekly') &&
|
const pace = window.pace;
|
||||||
window.resetMs !== undefined &&
|
const shouldShowPace = pace !== undefined;
|
||||||
window.windowDurationMs !== undefined;
|
|
||||||
|
|
||||||
let percentElapsed = 0;
|
// Marker position for pace indicator (shows elapsed time position on progress bar)
|
||||||
let paceDelta = 0;
|
|
||||||
let markerPosition = 0;
|
let markerPosition = 0;
|
||||||
|
|
||||||
if (shouldShowPace) {
|
if (shouldShowPace) {
|
||||||
percentElapsed = 100 - (window.resetMs! / window.windowDurationMs! * 100);
|
markerPosition = isRemainingMode ? (100 - pace.percentElapsed) : pace.percentElapsed;
|
||||||
paceDelta = window.percentUsed - percentElapsed; // positive = ahead of pace
|
|
||||||
|
|
||||||
// Marker position adjusts for view mode
|
|
||||||
markerPosition = isRemainingMode ? (100 - percentElapsed) : percentElapsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pace status thresholds
|
// Determine pace display status
|
||||||
const PACE_THRESHOLD = 5; // 5% threshold for "on pace"
|
const isAhead = pace?.status === "ahead";
|
||||||
const isAhead = paceDelta > PACE_THRESHOLD;
|
const isBehind = pace?.status === "behind";
|
||||||
const isBehind = paceDelta < -PACE_THRESHOLD;
|
const isOnTrack = pace?.status === "on-track";
|
||||||
const isOnTrack = !isAhead && !isBehind;
|
|
||||||
|
|
||||||
// Format pace delta for display (absolute value, rounded)
|
|
||||||
const paceDeltaFormatted = Math.abs(Math.round(paceDelta));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="usage-window">
|
<div className="usage-window">
|
||||||
@@ -101,28 +90,20 @@ function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
|
|||||||
<div className="usage-pace-row" data-testid="pace-row">
|
<div className="usage-pace-row" data-testid="pace-row">
|
||||||
{isAhead && (
|
{isAhead && (
|
||||||
<>
|
<>
|
||||||
<span className="pace-icon pace-icon-ahead">⚡</span>
|
<TrendingUp size={14} className="pace-icon pace-ahead" />
|
||||||
<span className="pace-text pace-ahead">
|
<span className="pace-text pace-ahead">{pace.message}</span>
|
||||||
{isRemainingMode
|
|
||||||
? `${paceDeltaFormatted}% behind on remaining`
|
|
||||||
: `${paceDeltaFormatted}% ahead of pace`}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isBehind && (
|
{isBehind && (
|
||||||
<>
|
<>
|
||||||
<span className="pace-icon pace-icon-behind">🐢</span>
|
<Info size={14} className="pace-icon pace-behind" />
|
||||||
<span className="pace-text pace-behind">
|
<span className="pace-text pace-behind">{pace.message}</span>
|
||||||
{isRemainingMode
|
|
||||||
? `${paceDeltaFormatted}% ahead on remaining`
|
|
||||||
: `${paceDeltaFormatted}% behind pace`}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isOnTrack && (
|
{isOnTrack && (
|
||||||
<>
|
<>
|
||||||
<span className="pace-icon pace-icon-ontrack">✓</span>
|
<CheckCircle size={14} className="pace-icon pace-ontrack" />
|
||||||
<span className="pace-text pace-ontrack">On pace</span>
|
<span className="pace-text pace-ontrack">{pace.message}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7190,16 +7190,19 @@ html .column.drag-over * {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ahead = using faster than expected (warning - over consuming) */
|
||||||
.pace-ahead {
|
.pace-ahead {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* on-track = good/expected usage */
|
||||||
|
.pace-ontrack {
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* behind = using slower than expected (info - under consuming) */
|
||||||
.pace-behind {
|
.pace-behind {
|
||||||
color: var(--triage);
|
color: #3b82f6; /* blue info color */
|
||||||
}
|
|
||||||
|
|
||||||
.pace-ontrack {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
fetchAllProviderUsage,
|
fetchAllProviderUsage,
|
||||||
clearUsageCache,
|
clearUsageCache,
|
||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
|
calculatePace,
|
||||||
} from "./usage.js";
|
} from "./usage.js";
|
||||||
|
|
||||||
// Mock the https module
|
// Mock the https module
|
||||||
@@ -38,10 +39,12 @@ describe("usage", () => {
|
|||||||
|
|
||||||
const providers = await fetchAllProviderUsage();
|
const providers = await fetchAllProviderUsage();
|
||||||
|
|
||||||
expect(providers).toHaveLength(3);
|
expect(providers).toHaveLength(5);
|
||||||
expect(providers.map((p) => p.name)).toContain("Claude");
|
expect(providers.map((p) => p.name)).toContain("Claude");
|
||||||
expect(providers.map((p) => p.name)).toContain("Codex");
|
expect(providers.map((p) => p.name)).toContain("Codex");
|
||||||
expect(providers.map((p) => p.name)).toContain("Gemini");
|
expect(providers.map((p) => p.name)).toContain("Gemini");
|
||||||
|
expect(providers.map((p) => p.name)).toContain("Minimax");
|
||||||
|
expect(providers.map((p) => p.name)).toContain("Zai");
|
||||||
|
|
||||||
// All should be no-auth status
|
// All should be no-auth status
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
@@ -76,7 +79,7 @@ describe("usage", () => {
|
|||||||
|
|
||||||
// Should be different array reference
|
// Should be different array reference
|
||||||
expect(second).not.toBe(first);
|
expect(second).not.toBe(first);
|
||||||
expect(second).toHaveLength(3);
|
expect(second).toHaveLength(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,6 +409,463 @@ describe("usage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Minimax provider", () => {
|
||||||
|
it("detects no auth when credentials file doesn't exist", async () => {
|
||||||
|
mockReadFileSync.mockImplementation(() => {
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const minimax = providers.find((p) => p.name === "Minimax");
|
||||||
|
|
||||||
|
expect(minimax).toBeDefined();
|
||||||
|
expect(minimax!.status).toBe("no-auth");
|
||||||
|
expect(minimax!.error).toContain("No Minimax credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects no auth when access_token is missing", async () => {
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("minimax")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
// missing access_token
|
||||||
|
refresh_token: "test-refresh",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const minimax = providers.find((p) => p.name === "Minimax");
|
||||||
|
|
||||||
|
expect(minimax!.status).toBe("no-auth");
|
||||||
|
expect(minimax!.error).toContain("No Minimax access token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses usage data from API response", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
quota: {
|
||||||
|
total: 1000,
|
||||||
|
used: 350,
|
||||||
|
remaining: 650,
|
||||||
|
},
|
||||||
|
reset_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
|
||||||
|
};
|
||||||
|
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("minimax")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "test-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File 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");
|
||||||
|
expect(minimax.windows).toHaveLength(1);
|
||||||
|
|
||||||
|
const weeklyWindow = minimax.windows[0];
|
||||||
|
expect(weeklyWindow.label).toBe("Weekly");
|
||||||
|
expect(weeklyWindow.percentUsed).toBe(35); // 350/1000 * 100
|
||||||
|
expect(weeklyWindow.percentLeft).toBe(65);
|
||||||
|
expect(weeklyWindow.resetText).toContain("resets in");
|
||||||
|
expect(weeklyWindow.resetMs).toBeDefined();
|
||||||
|
expect(weeklyWindow.windowDurationMs).toBe(7 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 401 auth error", async () => {
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("minimax")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "expired-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
on: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {},
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") {
|
||||||
|
handler(Buffer.from('{"error": "unauthorized"}'));
|
||||||
|
}
|
||||||
|
if (event === "end") {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
callback(mockRes);
|
||||||
|
return mockReq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const minimax = providers.find((p) => p.name === "Minimax")!;
|
||||||
|
|
||||||
|
expect(minimax.status).toBe("error");
|
||||||
|
expect(minimax.error).toContain("Auth expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 403 auth error", async () => {
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("minimax")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "forbidden-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
on: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 403,
|
||||||
|
headers: {},
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") {
|
||||||
|
handler(Buffer.from('{"error": "forbidden"}'));
|
||||||
|
}
|
||||||
|
if (event === "end") {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
callback(mockRes);
|
||||||
|
return mockReq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const minimax = providers.find((p) => p.name === "Minimax")!;
|
||||||
|
|
||||||
|
expect(minimax.status).toBe("error");
|
||||||
|
expect(minimax.error).toContain("Auth expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Zai provider", () => {
|
||||||
|
it("detects no auth when credentials file doesn't exist", async () => {
|
||||||
|
mockReadFileSync.mockImplementation(() => {
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const zai = providers.find((p) => p.name === "Zai");
|
||||||
|
|
||||||
|
expect(zai).toBeDefined();
|
||||||
|
expect(zai!.status).toBe("no-auth");
|
||||||
|
expect(zai!.error).toContain("No Zai credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects no auth when access_token is missing", async () => {
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("zai")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
// missing access_token
|
||||||
|
refresh_token: "test-refresh",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const zai = providers.find((p) => p.name === "Zai");
|
||||||
|
|
||||||
|
expect(zai!.status).toBe("no-auth");
|
||||||
|
expect(zai!.error).toContain("No Zai access token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses daily usage data from API response", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
total_credits: 10000,
|
||||||
|
used_credits: 2500,
|
||||||
|
reset_date: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), // 8 hours (daily)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("zai")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "test-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File 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 zai = providers.find((p) => p.name === "Zai")!;
|
||||||
|
|
||||||
|
expect(zai.status).toBe("ok");
|
||||||
|
expect(zai.windows).toHaveLength(1);
|
||||||
|
|
||||||
|
const dailyWindow = zai.windows[0];
|
||||||
|
expect(dailyWindow.label).toBe("Daily");
|
||||||
|
expect(dailyWindow.percentUsed).toBe(25); // 2500/10000 * 100
|
||||||
|
expect(dailyWindow.percentLeft).toBe(75);
|
||||||
|
expect(dailyWindow.resetText).toContain("resets in");
|
||||||
|
expect(dailyWindow.resetMs).toBeDefined();
|
||||||
|
expect(dailyWindow.windowDurationMs).toBe(24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses monthly usage data from API response", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
total_credits: 10000,
|
||||||
|
used_credits: 5000,
|
||||||
|
reset_date: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), // 15 days (monthly)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("zai")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "test-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File 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 zai = providers.find((p) => p.name === "Zai")!;
|
||||||
|
|
||||||
|
expect(zai.status).toBe("ok");
|
||||||
|
expect(zai.windows).toHaveLength(2);
|
||||||
|
|
||||||
|
const monthlyWindow = zai.windows[1];
|
||||||
|
expect(monthlyWindow.label).toBe("Monthly");
|
||||||
|
expect(monthlyWindow.percentUsed).toBe(50); // 5000/10000 * 100
|
||||||
|
expect(monthlyWindow.percentLeft).toBe(50);
|
||||||
|
expect(monthlyWindow.resetText).toContain("resets in");
|
||||||
|
expect(monthlyWindow.resetMs).toBeDefined();
|
||||||
|
expect(monthlyWindow.windowDurationMs).toBe(30 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 401 auth error", async () => {
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("zai")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "expired-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
on: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {},
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") {
|
||||||
|
handler(Buffer.from('{"error": "unauthorized"}'));
|
||||||
|
}
|
||||||
|
if (event === "end") {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
callback(mockRes);
|
||||||
|
return mockReq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const zai = providers.find((p) => p.name === "Zai")!;
|
||||||
|
|
||||||
|
expect(zai.status).toBe("error");
|
||||||
|
expect(zai.error).toContain("Auth expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 403 auth error", async () => {
|
||||||
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes("zai")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: "forbidden-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("File not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
on: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 403,
|
||||||
|
headers: {},
|
||||||
|
on: vi.fn((event: string, handler: any) => {
|
||||||
|
if (event === "data") {
|
||||||
|
handler(Buffer.from('{"error": "forbidden"}'));
|
||||||
|
}
|
||||||
|
if (event === "end") {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
callback(mockRes);
|
||||||
|
return mockReq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = await fetchAllProviderUsage();
|
||||||
|
const zai = providers.find((p) => p.name === "Zai")!;
|
||||||
|
|
||||||
|
expect(zai.status).toBe("error");
|
||||||
|
expect(zai.error).toContain("Auth expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculatePace helper", () => {
|
||||||
|
it("returns ahead status when usage exceeds elapsed time by >5%", () => {
|
||||||
|
// 70% used, 50% elapsed = 20% ahead (3 days remaining out of 7 = 57% elapsed, 70 - 57 = 13 > 5)
|
||||||
|
// Actually: 100 - (3/7 * 100) = 57.14% elapsed
|
||||||
|
// 70 - 57.14 = 12.86% > 5% → ahead
|
||||||
|
const pace = calculatePace(70, 3 * 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(pace).toBeDefined();
|
||||||
|
expect(pace!.status).toBe("ahead");
|
||||||
|
expect(pace!.percentElapsed).toBe(57);
|
||||||
|
expect(pace!.message).toContain("over pace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns behind status when usage is under elapsed time by >5%", () => {
|
||||||
|
// 20% used, 57% elapsed = 37% behind
|
||||||
|
const pace = calculatePace(20, 3 * 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(pace).toBeDefined();
|
||||||
|
expect(pace!.status).toBe("behind");
|
||||||
|
expect(pace!.percentElapsed).toBe(57);
|
||||||
|
expect(pace!.message).toContain("under pace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns on-track status when within 5% of elapsed time", () => {
|
||||||
|
// 52% used, 57% elapsed = 5% difference (within threshold)
|
||||||
|
const pace = calculatePace(52, 3.5 * 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(pace).toBeDefined();
|
||||||
|
expect(pace!.status).toBe("on-track");
|
||||||
|
expect(pace!.message).toBe("On pace with time elapsed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when resetMs is undefined", () => {
|
||||||
|
const pace = calculatePace(50, undefined, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(pace).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when windowDurationMs is undefined", () => {
|
||||||
|
const pace = calculatePace(50, 3 * 24 * 60 * 60 * 1000, undefined);
|
||||||
|
expect(pace).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when resetMs is 0 or negative", () => {
|
||||||
|
expect(calculatePace(50, 0, 7 * 24 * 60 * 60 * 1000)).toBeUndefined();
|
||||||
|
expect(calculatePace(50, -1000, 7 * 24 * 60 * 60 * 1000)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when windowDurationMs is 0 or negative", () => {
|
||||||
|
expect(calculatePace(50, 3 * 24 * 60 * 60 * 1000, 0)).toBeUndefined();
|
||||||
|
expect(calculatePace(50, 3 * 24 * 60 * 60 * 1000, -1000)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps percentUsed to 0-100 range", () => {
|
||||||
|
// Test with negative percentUsed
|
||||||
|
let pace = calculatePace(-10, 3 * 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(pace).toBeDefined();
|
||||||
|
expect(pace!.status).toBe("behind");
|
||||||
|
|
||||||
|
// Test with percentUsed > 100
|
||||||
|
pace = calculatePace(150, 3 * 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(pace).toBeDefined();
|
||||||
|
expect(pace!.status).toBe("ahead");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("error handling", () => {
|
||||||
it("handles network errors gracefully", async () => {
|
it("handles network errors gracefully", async () => {
|
||||||
mockReadFileSync.mockImplementation((path: string) => {
|
mockReadFileSync.mockImplementation((path: string) => {
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as https from "node:https";
|
import * as https from "node:https";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pace information for weekly usage windows
|
||||||
|
*/
|
||||||
|
export interface UsagePace {
|
||||||
|
status: "ahead" | "on-track" | "behind";
|
||||||
|
percentElapsed: number; // 0-100
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Usage window for a provider (e.g., "Session (5h)", "Weekly")
|
* Usage window for a provider (e.g., "Session (5h)", "Weekly")
|
||||||
*/
|
*/
|
||||||
@@ -12,6 +21,7 @@ export interface UsageWindow {
|
|||||||
resetText: string | null; // e.g., "resets in 2h"
|
resetText: string | null; // e.g., "resets in 2h"
|
||||||
resetMs?: number; // ms until reset
|
resetMs?: number; // ms until reset
|
||||||
windowDurationMs?: number; // total window length
|
windowDurationMs?: number; // total window length
|
||||||
|
pace?: UsagePace; // pace indicator for weekly windows
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +54,77 @@ interface CacheEntry {
|
|||||||
let usageCache: CacheEntry | null = null;
|
let usageCache: CacheEntry | null = null;
|
||||||
const CACHE_TTL_MS = 30_000; // 30 seconds
|
const CACHE_TTL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
// Pace threshold - matches frontend UsageIndicator.tsx
|
||||||
|
const PACE_THRESHOLD = 5; // 5% threshold for "on pace"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate pace information for a usage window.
|
||||||
|
* Returns undefined if pace cannot be calculated (e.g., missing timing data or window reset).
|
||||||
|
*/
|
||||||
|
export function calculatePace(
|
||||||
|
percentUsed: number,
|
||||||
|
resetMs: number | undefined,
|
||||||
|
windowDurationMs: number | undefined
|
||||||
|
): UsagePace | undefined {
|
||||||
|
// Validate inputs
|
||||||
|
if (resetMs === undefined || windowDurationMs === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window already reset or invalid duration
|
||||||
|
if (resetMs <= 0 || windowDurationMs <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp percentUsed to valid range
|
||||||
|
const clampedPercentUsed = Math.min(100, Math.max(0, percentUsed));
|
||||||
|
|
||||||
|
// Calculate percent of time elapsed in the window
|
||||||
|
// percentElapsed = 100 - (remainingTime / totalTime * 100)
|
||||||
|
const percentElapsed = 100 - (resetMs / windowDurationMs * 100);
|
||||||
|
|
||||||
|
// Calculate delta between usage and elapsed time
|
||||||
|
const paceDelta = clampedPercentUsed - percentElapsed;
|
||||||
|
|
||||||
|
// Determine status based on threshold
|
||||||
|
if (paceDelta > PACE_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
status: "ahead",
|
||||||
|
percentElapsed: Math.round(percentElapsed),
|
||||||
|
message: `Using ${Math.abs(Math.round(paceDelta))}% over pace`,
|
||||||
|
};
|
||||||
|
} else if (paceDelta < -PACE_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
status: "behind",
|
||||||
|
percentElapsed: Math.round(percentElapsed),
|
||||||
|
message: `Using ${Math.abs(Math.round(paceDelta))}% under pace`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: "on-track",
|
||||||
|
percentElapsed: Math.round(percentElapsed),
|
||||||
|
message: "On pace with time elapsed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply pace calculation to a usage window if applicable.
|
||||||
|
* Only applies to weekly windows with valid timing data.
|
||||||
|
*/
|
||||||
|
function applyPaceToWindow(window: UsageWindow): UsageWindow {
|
||||||
|
// Only apply pace to weekly windows
|
||||||
|
if (!window.label.toLowerCase().includes("weekly")) {
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pace = calculatePace(window.percentUsed, window.resetMs, window.windowDurationMs);
|
||||||
|
if (pace) {
|
||||||
|
return { ...window, pace };
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format duration in milliseconds to human-readable string
|
* Format duration in milliseconds to human-readable string
|
||||||
*/
|
*/
|
||||||
@@ -484,6 +565,203 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
|||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Minimax fetcher ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchMinimaxUsage(): Promise<ProviderUsage> {
|
||||||
|
const usage: ProviderUsage = {
|
||||||
|
name: "Minimax",
|
||||||
|
icon: "🟣",
|
||||||
|
status: "no-auth",
|
||||||
|
windows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load Minimax credentials
|
||||||
|
const credPath = path.join(process.env.HOME || "~", ".minimax", "credentials.json");
|
||||||
|
let creds: any = null;
|
||||||
|
try {
|
||||||
|
creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
usage.error = "No Minimax credentials configured";
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = creds?.access_token;
|
||||||
|
if (!accessToken) {
|
||||||
|
usage.error = "No Minimax access token found";
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await httpsRequest("https://api.minimaxi.com/user/quota", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
usage.status = "error";
|
||||||
|
usage.error = "Auth expired";
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
usage.status = "error";
|
||||||
|
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
usage.status = "ok";
|
||||||
|
|
||||||
|
const quota = data?.quota;
|
||||||
|
if (quota && typeof quota === "object") {
|
||||||
|
const total: number = quota.total ?? 0;
|
||||||
|
const used: number = quota.used ?? 0;
|
||||||
|
const remaining: number = quota.remaining ?? Math.max(0, total - used);
|
||||||
|
|
||||||
|
const percentUsed = total > 0 ? (used / total) * 100 : 0;
|
||||||
|
|
||||||
|
let resetText: string | null = null;
|
||||||
|
let resetMs: number | undefined;
|
||||||
|
let windowDurationMs: number | undefined;
|
||||||
|
|
||||||
|
const resetAt = data?.reset_at;
|
||||||
|
if (resetAt) {
|
||||||
|
const msLeft = new Date(resetAt).getTime() - Date.now();
|
||||||
|
resetMs = msLeft > 0 ? msLeft : 0;
|
||||||
|
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||||
|
// Weekly window duration (7 days)
|
||||||
|
windowDurationMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.windows.push({
|
||||||
|
label: "Weekly",
|
||||||
|
percentUsed: Math.min(100, Math.max(0, percentUsed)),
|
||||||
|
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
|
||||||
|
resetText,
|
||||||
|
resetMs,
|
||||||
|
windowDurationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
usage.status = "error";
|
||||||
|
usage.error = e.message || "Failed to fetch";
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zai (Zhipu AI) fetcher ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchZaiUsage(): Promise<ProviderUsage> {
|
||||||
|
const usage: ProviderUsage = {
|
||||||
|
name: "Zai",
|
||||||
|
icon: "🟡",
|
||||||
|
status: "no-auth",
|
||||||
|
windows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load Zai credentials
|
||||||
|
const authPath = path.join(process.env.HOME || "~", ".zai", "auth.json");
|
||||||
|
let auth: any = null;
|
||||||
|
try {
|
||||||
|
auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
usage.error = "No Zai credentials configured";
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = auth?.access_token;
|
||||||
|
if (!accessToken) {
|
||||||
|
usage.error = "No Zai access token found";
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await httpsRequest("https://api.zhipuai.com/v1/user/usage", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
usage.status = "error";
|
||||||
|
usage.error = "Auth expired";
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
usage.status = "error";
|
||||||
|
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(res.body);
|
||||||
|
usage.status = "ok";
|
||||||
|
|
||||||
|
const usageData = data?.data;
|
||||||
|
if (usageData && typeof usageData === "object") {
|
||||||
|
const totalCredits: number = usageData.total_credits ?? 0;
|
||||||
|
const usedCredits: number = usageData.used_credits ?? 0;
|
||||||
|
|
||||||
|
const percentUsed = totalCredits > 0 ? (usedCredits / totalCredits) * 100 : 0;
|
||||||
|
|
||||||
|
let dailyResetText: string | null = null;
|
||||||
|
let dailyResetMs: number | undefined;
|
||||||
|
let monthlyResetText: string | null = null;
|
||||||
|
let monthlyResetMs: number | undefined;
|
||||||
|
|
||||||
|
const resetDate = usageData.reset_date;
|
||||||
|
if (resetDate) {
|
||||||
|
const resetTime = new Date(resetDate).getTime();
|
||||||
|
const msLeft = resetTime - Date.now();
|
||||||
|
|
||||||
|
// Determine if this is daily or monthly based on time until reset
|
||||||
|
const hoursLeft = msLeft / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (hoursLeft <= 24) {
|
||||||
|
// Daily window
|
||||||
|
dailyResetMs = msLeft > 0 ? msLeft : 0;
|
||||||
|
dailyResetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||||
|
} else {
|
||||||
|
// Monthly window
|
||||||
|
monthlyResetMs = msLeft > 0 ? msLeft : 0;
|
||||||
|
monthlyResetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Daily window
|
||||||
|
usage.windows.push({
|
||||||
|
label: "Daily",
|
||||||
|
percentUsed: Math.min(100, Math.max(0, percentUsed)),
|
||||||
|
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
|
||||||
|
resetText: dailyResetText,
|
||||||
|
resetMs: dailyResetMs,
|
||||||
|
windowDurationMs: dailyResetMs ? 24 * 60 * 60 * 1000 : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Monthly window if applicable
|
||||||
|
if (monthlyResetMs) {
|
||||||
|
usage.windows.push({
|
||||||
|
label: "Monthly",
|
||||||
|
percentUsed: Math.min(100, Math.max(0, percentUsed)),
|
||||||
|
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
|
||||||
|
resetText: monthlyResetText,
|
||||||
|
resetMs: monthlyResetMs,
|
||||||
|
windowDurationMs: 30 * 24 * 60 * 60 * 1000, // Approximate 30 days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
usage.status = "error";
|
||||||
|
usage.error = e.message || "Failed to fetch";
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main export ────────────────────────────────────────────────────────────
|
// ── Main export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -501,12 +779,17 @@ export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Pro
|
|||||||
fetchClaudeUsage(),
|
fetchClaudeUsage(),
|
||||||
fetchCodexUsage(),
|
fetchCodexUsage(),
|
||||||
fetchGeminiUsage(),
|
fetchGeminiUsage(),
|
||||||
|
fetchMinimaxUsage(),
|
||||||
|
fetchZaiUsage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const providers: ProviderUsage[] = [];
|
const providers: ProviderUsage[] = [];
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.status === "fulfilled") {
|
if (r.status === "fulfilled") {
|
||||||
providers.push(r.value);
|
// Apply pace calculation to all windows
|
||||||
|
const provider = r.value;
|
||||||
|
provider.windows = provider.windows.map(applyPaceToWindow);
|
||||||
|
providers.push(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user