Fix Zai and Minimax auth for usage

This commit is contained in:
gsxdsm
2026-04-13 07:38:38 -07:00
parent bbe2542362
commit a2d54c4a42
9 changed files with 332 additions and 95 deletions

View File

@@ -21,7 +21,7 @@ import { githubRateLimiter } from "./github-poll.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, readProjectFile, writeProjectFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse, type FileOperationResponse } from "./file-service.js";
import { fetchAllProviderUsage } from "./usage.js";
import { clearUsageCache, fetchAllProviderUsage } from "./usage.js";
import {
getGitHubAppConfig,
verifyWebhookSignature,
@@ -97,6 +97,10 @@ export interface AuthStorageLike {
clearApiKey?(providerId: string): void;
/** Check if a provider has an API key configured. */
hasApiKey?(providerId: string): boolean;
/** Get the configured API key for usage providers. */
getApiKey?(providerId: string): string | null | undefined | Promise<string | null | undefined>;
/** Get raw stored credentials for usage providers. */
get?(providerId: string): { type?: string; key?: string } | null | undefined;
}
const upload = multer({
@@ -8074,7 +8078,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/usage", async (_req, res) => {
try {
const providers = await fetchAllProviderUsage();
const providers = await fetchAllProviderUsage(options?.authStorage);
res.json({ providers });
} catch (err: any) {
if (err instanceof ApiError) {
@@ -14833,6 +14837,7 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
}
storage.setApiKey(provider, apiKey.trim());
clearUsageCache();
res.json({ success: true });
} catch (err: any) {
if (err instanceof ApiError) {
@@ -14861,6 +14866,7 @@ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void
}
storage.clearApiKey(provider);
clearUsageCache();
res.json({ success: true });
} catch (err: any) {
if (err instanceof ApiError) {

View File

@@ -2049,6 +2049,55 @@ describe("usage", () => {
expect(minimax.windows[0].label).toBe("MiniMax-M*");
});
it("reads API key from provided AuthStorage before auth files", async () => {
const mockResponse = {
model_remains: [
{
model_name: "MiniMax-M*",
current_interval_total_count: 100,
current_interval_usage_count: 40,
remains_time: 60_000,
start_time: Date.now() - 60_000,
end_time: Date.now() + 60_000,
},
],
};
mockReadFileSync.mockImplementation(() => {
throw 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) => {
expect(options.headers.authorization).toBe("Bearer auth-storage-minimax-key");
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({
reload: vi.fn(),
hasAuth: vi.fn(() => true),
getApiKey: vi.fn((provider: string) =>
provider === "minimax" ? "auth-storage-minimax-key" : null
),
});
const minimax = providers.find((p) => p.name === "Minimax")!;
expect(minimax.status).toBe("ok");
expect(minimax.windows).toHaveLength(1);
});
it("handles 401 auth error", async () => {
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".pi/agent/auth.json")) {
@@ -2280,6 +2329,106 @@ describe("usage", () => {
expect(mcpWindow.percentUsed).toBe(2.5);
});
it("reads API key from provided AuthStorage before auth files", async () => {
const mockResponse = {
code: 200,
msg: "Operation successful",
data: {
limits: [
{
type: "TOKENS_LIMIT",
percentage: 10,
nextResetTime: Date.now() + 60_000,
},
],
level: "pro",
},
success: true,
};
mockReadFileSync.mockImplementation(() => {
throw 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) => {
expect(options.headers.authorization).toBe("auth-storage-zai-key");
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({
reload: vi.fn(),
hasAuth: vi.fn(() => true),
get: vi.fn((provider: string) =>
provider === "zai" ? { type: "api_key", key: "auth-storage-zai-key" } : null
),
});
const zai = providers.find((p) => p.name === "Zai")!;
expect(zai.status).toBe("ok");
expect(zai.plan).toBe("Pro");
});
it("falls back to fusion auth files when pi auth files are absent", async () => {
const mockResponse = {
code: 200,
msg: "Operation successful",
data: {
limits: [
{
type: "TOKENS_LIMIT",
percentage: 10,
nextResetTime: Date.now() + 60_000,
},
],
},
success: true,
};
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes(".fusion/agent/auth.json")) {
return JSON.stringify({
zai: { type: "api_key", key: "fusion-zai-key" },
});
}
throw 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) => {
expect(options.headers.authorization).toBe("fusion-zai-key");
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");
});
it("parses only TOKENS_LIMIT when no TIME_LIMIT present", async () => {
const mockResponse = {
code: 200,

View File

@@ -61,6 +61,8 @@ export interface ProviderUsage {
export interface AuthStorageLike {
reload(): void;
hasAuth(provider: string): boolean;
get?(provider: string): { type?: string; key?: string } | null | undefined;
getApiKey?(provider: string): string | null | undefined | Promise<string | null | undefined>;
}
// Cache for usage data with TTL
@@ -227,12 +229,21 @@ function decodeJwtPayload(token: string): any {
// ── Pi auth storage reader ──────────────────────────────────────────────────
/**
* Read an API key from pi's auth storage (~/.pi/agent/auth.json).
* Returns the API key string or null if not found.
*/
function readPiAuthKey(provider: string): string | null {
const authPath = path.join(process.env.HOME || "~", ".pi", "agent", "auth.json");
function getAuthFileCandidates(): string[] {
const home = process.env.HOME || "~";
return [
path.join(home, ".pi", "agent", "auth.json"),
path.join(home, ".pi", "auth.json"),
path.join(home, ".fusion", "agent", "auth.json"),
path.join(home, ".fusion", "auth.json"),
path.join(process.cwd(), ".fusion", "agent", "auth.json"),
path.join(process.cwd(), ".fusion", "auth.json"),
path.join(process.cwd(), ".pi", "agent", "auth.json"),
path.join(process.cwd(), ".pi", "auth.json"),
];
}
function readAuthKeyFromFile(authPath: string, provider: string): string | null {
try {
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
const entry = auth?.[provider];
@@ -243,6 +254,35 @@ function readPiAuthKey(provider: string): string | null {
return null;
}
/**
* Read an API key from the same AuthStorage object used by the dashboard when
* available, then fall back to conventional pi/fusion auth files.
*/
async function readConfiguredApiKey(provider: string, authStorage?: AuthStorageLike): Promise<string | null> {
try {
authStorage?.reload();
} catch {}
try {
const apiKey = await authStorage?.getApiKey?.(provider);
if (apiKey) return apiKey;
} catch {}
try {
const entry = authStorage?.get?.(provider);
if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
return entry.key;
}
} catch {}
for (const authPath of getAuthFileCandidates()) {
const apiKey = readAuthKeyFromFile(authPath, provider);
if (apiKey) return apiKey;
}
return null;
}
// ── Claude fetcher ─────────────────────────────────────────────────────────
/**
@@ -1222,7 +1262,7 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
// ── Minimax fetcher ─────────────────────────────────────────────────────────
async function fetchMinimaxUsage(): Promise<ProviderUsage> {
async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Minimax",
icon: "🟣",
@@ -1230,8 +1270,8 @@ async function fetchMinimaxUsage(): Promise<ProviderUsage> {
windows: [],
};
// Load Minimax API key from pi's auth storage
const apiKey = readPiAuthKey("minimax");
// Load Minimax API key from the same auth storage the dashboard uses.
const apiKey = await readConfiguredApiKey("minimax", authStorage);
if (!apiKey) {
usage.error = "No Minimax credentials — add API key to pi";
return usage;
@@ -1316,7 +1356,7 @@ async function fetchMinimaxUsage(): Promise<ProviderUsage> {
// ── Zai (Zhipu AI) fetcher ──────────────────────────────────────────────────
async function fetchZaiUsage(): Promise<ProviderUsage> {
async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Zai",
icon: "🟡",
@@ -1324,8 +1364,8 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
windows: [],
};
// Load Zai API key from pi's auth storage
const apiKey = readPiAuthKey("zai");
// Load Zai API key from the same auth storage the dashboard uses.
const apiKey = await readConfiguredApiKey("zai", authStorage);
if (!apiKey) {
usage.error = "No Zai credentials — add API key to pi";
return usage;
@@ -1496,7 +1536,7 @@ export function withTimeout(
});
}
export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Promise<ProviderUsage[]> {
export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Promise<ProviderUsage[]> {
// Check cache
if (usageCache && Date.now() - usageCache.timestamp < CACHE_TTL_MS) {
return usageCache.data;
@@ -1508,8 +1548,8 @@ export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Pro
withTimeout(fetchClaudeUsage(), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
withTimeout(fetchCodexUsage(), "Codex"),
withTimeout(fetchGeminiUsage(), "Gemini"),
withTimeout(fetchMinimaxUsage(), "Minimax"),
withTimeout(fetchZaiUsage(), "Zai"),
withTimeout(fetchMinimaxUsage(authStorage), "Minimax"),
withTimeout(fetchZaiUsage(authStorage), "Zai"),
]);
const providers: ProviderUsage[] = [];