feat: live multi-provider quota panel (#1278)
* feat: live provider quota panel (Claude + Codex) Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address review feedback - restore quota trigger, guard NaN date - Re-add triggerFetch() on agent idle/error in agent status subscription - Guard formatResetsAtLabel against NaN from malformed API date strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve composer conflict and stub subscribe in websocket tests * feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety * feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics * security(quota): fix shell injection vulnerability by migrating exec to execFile * revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1 * Fix i18n resource test expectation * Fix quota tooltip empty-provider state * Preserve zero values in Grok quota * Fix empty quota fetch state * Ignore quota frames in relay reconnect tests * Persist refreshed Codex tokens to source auth file * fix(quota): read Claude OAuth credentials from the macOS login Keychain On macOS, Claude Code stores its OAuth credential in the login Keychain (generic-password item, service "Claude Code-credentials"), not in ~/.claude/.credentials.json — that file usually does not exist there, so the Claude provider's existsSync check failed and macOS users silently got no Claude quota. Read it via the macOS `security` CLI (its ACL only trusts /usr/bin/security to decrypt; a native Keychain read would prompt), and stay read-only there since Claude Code owns the refresh/persist. Linux and Windows keep using ~/.claude/.credentials.json unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix quota keychain timeout and local host reconciliation * Fix terminal notification test quota stub * Ignore quota frames in terminal notification tests * fix(quota): bypass Claude token refresh on macOS Keychain-backed logins * fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1 * fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1 * fix(client): handle and dispatch provider_quota event in DaemonClient * fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding * fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs * fix(desktop): fix concurrently command invocation on Windows in dev.ps1 * fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows * Add on-demand provider usage views * Fix Cursor usage billing dates * Move provider usage renderer coverage to e2e * Use provider manifest for quota fetchers * Add provider usage fetch timeouts * Reshape provider usage fetchers --------- Co-authored-by: ABorakati <ABorakati@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> Co-authored-by: lumingjun <lumingjun@bytedance.com>
This commit is contained in:
co-authored by
ABorakati
Claude Sonnet 4.6
Mohamed Boudra
lumingjun
parent
2b0740ff84
commit
acea7f3d24
@@ -11,7 +11,7 @@ import type {
|
||||
ResolveAgentCreateConfigInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
@@ -873,4 +873,13 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => {
|
||||
const resolved = resolveSnapshotCwd("C:");
|
||||
if (process.platform === "win32") {
|
||||
expect(resolved).toBe("C:\\");
|
||||
} else {
|
||||
expect(resolved).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -766,9 +766,16 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||
if (!trimmed) {
|
||||
return homedir();
|
||||
}
|
||||
const expanded =
|
||||
let expanded =
|
||||
trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed;
|
||||
return resolve(expanded);
|
||||
if (process.platform === "win32" && /^[A-Za-z]:$/.test(expanded)) {
|
||||
expanded = `${expanded}\\`;
|
||||
}
|
||||
let resolved = resolve(expanded);
|
||||
if (process.platform === "win32" && /^[A-Za-z]:$/.test(resolved)) {
|
||||
resolved = `${resolved}\\`;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function entriesToArray(
|
||||
|
||||
@@ -35,7 +35,7 @@ export class MockSlowProviderClient implements AgentClient {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
|
||||
}
|
||||
|
||||
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
||||
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
@@ -1996,21 +1998,33 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const launch = await this.resolvePiLaunch();
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.available) {
|
||||
return false;
|
||||
}
|
||||
const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null);
|
||||
if (!runtimeSession) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await runtimeSession.getAvailableModels()).length > 0;
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const launch = await this.resolvePiLaunch();
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.available) {
|
||||
return false;
|
||||
}
|
||||
const runtimeSession = await this.runtime
|
||||
.startSession({ cwd: homedir() })
|
||||
.catch(() => null);
|
||||
if (!runtimeSession) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await runtimeSession.getAvailableModels()).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
}
|
||||
})(),
|
||||
2000,
|
||||
"Pi availability check timed out",
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -436,6 +436,19 @@ describe("paseo daemon bootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses IPv6 listen targets correctly", () => {
|
||||
expect(parseListenString("[::1]:6767")).toEqual({
|
||||
type: "tcp",
|
||||
host: "::1",
|
||||
port: 6767,
|
||||
});
|
||||
expect(parseListenString("[::]:6767")).toEqual({
|
||||
type: "tcp",
|
||||
host: "::",
|
||||
port: 6767,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Windows absolute paths that are not named pipes", () => {
|
||||
// A Windows drive path like C:\daemon must NOT be silently parsed as TCP
|
||||
// (split(":") would yield host="C" and port="\\daemon" which is nonsensical).
|
||||
|
||||
@@ -66,12 +66,15 @@ export function parseListenString(listen: string): ListenTarget {
|
||||
}
|
||||
// 6. host:port — TCP
|
||||
if (listen.includes(":")) {
|
||||
const [host, portStr] = listen.split(":");
|
||||
const lastColonIdx = listen.lastIndexOf(":");
|
||||
const host = listen.slice(0, lastColonIdx);
|
||||
const portStr = listen.slice(lastColonIdx + 1);
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(parsedPort)) {
|
||||
throw new Error(`Invalid port in listen string: ${listen}`);
|
||||
}
|
||||
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
|
||||
const cleanHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
return { type: "tcp", host: cleanHost || "127.0.0.1", port: parsedPort };
|
||||
}
|
||||
throw new Error(`Invalid listen string: ${listen}`);
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ import {
|
||||
type GitHubService,
|
||||
type PullRequestTimelineItem,
|
||||
} from "../services/github-service.js";
|
||||
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||
import {
|
||||
summarizeFetchWorkspacesEntries,
|
||||
workspaceIdsOnCheckout,
|
||||
@@ -631,6 +632,7 @@ export interface SessionOptions {
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
providerUsageService: ProviderUsageService;
|
||||
serviceProxy?: ServiceProxySubsystem;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
|
||||
@@ -865,6 +867,7 @@ export class Session {
|
||||
} | null = null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private readonly providerUsageService: ProviderUsageService;
|
||||
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
|
||||
private readonly serviceProxy: ServiceProxySubsystem | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
@@ -934,6 +937,7 @@ export class Session {
|
||||
tts,
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
providerUsageService,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
workspaceSetupSnapshots,
|
||||
@@ -1027,6 +1031,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.providerSnapshotManager = providerSnapshotManager;
|
||||
this.providerUsageService = providerUsageService;
|
||||
this.serviceProxy = serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
|
||||
@@ -2282,6 +2287,8 @@ export class Session {
|
||||
return this.handleRefreshProvidersSnapshotRequest(msg);
|
||||
case "provider_diagnostic_request":
|
||||
return this.handleProviderDiagnosticRequest(msg);
|
||||
case "provider.usage.list.request":
|
||||
return this.handleProviderUsageListRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -4394,6 +4401,34 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProviderUsageListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "provider.usage.list.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const usage = await this.providerUsageService.listUsage();
|
||||
this.emit({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
fetchedAt: usage.fetchedAt,
|
||||
providers: usage.providers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.sessionLogger.error({ err }, "Failed to list provider usage");
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: msg.type,
|
||||
error: `Failed to list provider usage: ${err.message}`,
|
||||
code: "provider_usage_list_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
|
||||
@@ -76,6 +76,7 @@ class RecordingPushNotificationSender implements PushNotificationSender {
|
||||
function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const pushNotifications = new RecordingPushNotificationSender();
|
||||
const agentManager = {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
|
||||
@@ -125,6 +125,30 @@ function parseSentEnvelope(data: unknown): z.infer<typeof WireEnvelopeSchema> {
|
||||
return WireEnvelopeSchema.parse(JSON.parse(data));
|
||||
}
|
||||
|
||||
function sentEnvelopes(socket: MockSocket): z.infer<typeof WireEnvelopeSchema>[] {
|
||||
return socket.sent.filter((data) => typeof data === "string").map(parseSentEnvelope);
|
||||
}
|
||||
|
||||
function sentServerInfoEnvelopes(socket: MockSocket): z.infer<typeof WireEnvelopeSchema>[] {
|
||||
return sentEnvelopes(socket).filter(
|
||||
(envelope) => parseServerInfoStatusPayload(envelope.message?.payload) !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function sentBinaryFrames(socket: MockSocket): Uint8Array[] {
|
||||
return socket.sent.map(asUint8Array).filter((frame): frame is Uint8Array => frame !== null);
|
||||
}
|
||||
|
||||
function sentTerminalFrames(
|
||||
socket: MockSocket,
|
||||
): NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>[] {
|
||||
return sentBinaryFrames(socket)
|
||||
.map(decodeTerminalStreamFrame)
|
||||
.filter(
|
||||
(frame): frame is NonNullable<ReturnType<typeof decodeTerminalStreamFrame>> => frame !== null,
|
||||
);
|
||||
}
|
||||
|
||||
const BinaryFrameSchema = z.object({
|
||||
kind: z.literal("terminal"),
|
||||
frame: z.object({
|
||||
@@ -201,6 +225,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
createStub<pino.Logger>(createLogger()),
|
||||
"srv_test",
|
||||
createStub<AgentManager>({
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
@@ -705,20 +730,20 @@ describe("relay external socket reconnect behavior", () => {
|
||||
socket,
|
||||
clientId: "cid-server-info-broadcast",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(1);
|
||||
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
server.publishSpeechReadiness(speechReadiness);
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(2);
|
||||
|
||||
const secondEnvelope = parseSentEnvelope(socket.sent[1]);
|
||||
const secondEnvelope = sentServerInfoEnvelopes(socket)[1];
|
||||
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
|
||||
expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
||||
expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(true);
|
||||
|
||||
// Same readiness should not produce another server_info broadcast.
|
||||
server.publishSpeechReadiness(speechReadiness);
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(2);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
@@ -731,12 +756,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
socket,
|
||||
clientId: "cid-server-info-download-guidance",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(1);
|
||||
|
||||
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(2);
|
||||
|
||||
const envelope = parseSentEnvelope(socket.sent[1]);
|
||||
const envelope = sentServerInfoEnvelopes(socket)[1];
|
||||
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
||||
expect(payload?.capabilities?.voice?.voice.enabled).toBe(true);
|
||||
@@ -797,14 +822,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
onBinaryMessage(new Uint8Array([TerminalStreamOpcode.Output, 12, 0x6f, 0x6b]));
|
||||
}
|
||||
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
const binaryPayload = asUint8Array(socket.sent[1]);
|
||||
expect(binaryPayload).not.toBeNull();
|
||||
const frame = decodeTerminalStreamFrame(binaryPayload!);
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame!.opcode).toBe(TerminalStreamOpcode.Output);
|
||||
expect(frame!.slot).toBe(12);
|
||||
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
|
||||
const terminalFrames = sentTerminalFrames(socket);
|
||||
expect(terminalFrames).toHaveLength(1);
|
||||
const frame = terminalFrames[0];
|
||||
expect(frame.opcode).toBe(TerminalStreamOpcode.Output);
|
||||
expect(frame.slot).toBe(12);
|
||||
expect(new TextDecoder().decode(frame.payload ?? new Uint8Array())).toBe("ok");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
|
||||
const pushNotifications = new RecordingPushNotificationSender();
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
@@ -200,22 +201,40 @@ function connectClient(server: VoiceAssistantWebSocketServer) {
|
||||
return ws;
|
||||
}
|
||||
|
||||
function sentTerminalAttentionMessages(ws: ReturnType<typeof createOpenSocket>) {
|
||||
return ws.send.mock.calls
|
||||
.map(([rawMessage]) => {
|
||||
expect(typeof rawMessage).toBe("string");
|
||||
if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame");
|
||||
return JSON.parse(rawMessage);
|
||||
})
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "session" && message.message.type === "terminal_attention_required",
|
||||
)
|
||||
.map((message) => message.message.payload as TerminalAttentionPayload);
|
||||
}
|
||||
|
||||
interface TerminalAttentionPayload {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
shouldNotify: boolean;
|
||||
reason: "finished" | "needs_input";
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function readTerminalAttentionMessage(ws: ReturnType<typeof createOpenSocket>) {
|
||||
const rawMessage = ws.send.mock.calls[0]?.[0];
|
||||
expect(typeof rawMessage).toBe("string");
|
||||
if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame");
|
||||
const message = JSON.parse(rawMessage);
|
||||
expect(message.type).toBe("session");
|
||||
expect(message.message.type).toBe("terminal_attention_required");
|
||||
return message.message.payload as {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
shouldNotify: boolean;
|
||||
reason: "finished" | "needs_input";
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
const terminalMessages = sentTerminalAttentionMessages(ws);
|
||||
expect(terminalMessages).toHaveLength(1);
|
||||
const [payload] = terminalMessages;
|
||||
if (!payload) throw new Error("Expected terminal attention message");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function expectNoTerminalAttentionMessage(ws: ReturnType<typeof createOpenSocket>) {
|
||||
expect(sentTerminalAttentionMessages(ws)).toHaveLength(0);
|
||||
}
|
||||
|
||||
const CWD = "/home/user/project";
|
||||
@@ -357,7 +376,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
|
||||
|
||||
await flushAsync();
|
||||
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
expectNoTerminalAttentionMessage(ws);
|
||||
});
|
||||
|
||||
it("does not broadcast on working -> unknown", async () => {
|
||||
@@ -376,7 +395,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
|
||||
|
||||
await flushAsync();
|
||||
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
expectNoTerminalAttentionMessage(ws);
|
||||
});
|
||||
|
||||
it("broadcasts needs_input after working -> attention", async () => {
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
WebSocketRuntimeMetricsWindow,
|
||||
type WebSocketRuntimeCounters,
|
||||
} from "./websocket/runtime-metrics.js";
|
||||
import { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
@@ -407,6 +408,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private eventLoopDelayMonitor: ReturnType<typeof monitorEventLoopDelay> | null = null;
|
||||
private unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
private unsubscribeDaemonConfigChange: (() => void) | null = null;
|
||||
private readonly providerUsageService: ProviderUsageService;
|
||||
private unsubscribeTerminalActivity: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -533,6 +535,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
});
|
||||
});
|
||||
|
||||
this.providerUsageService = new ProviderUsageService({
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
this.wss = this.createWebSocketServer(server, wsConfig, auth);
|
||||
this.startRuntimeMetricsInterval();
|
||||
|
||||
@@ -977,6 +983,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
providerUsageService: this.providerUsageService,
|
||||
serviceProxy: this.serviceProxy ?? undefined,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
|
||||
@@ -1161,6 +1168,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
projectRemove: true,
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
worktreeRestore: true,
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
ProviderUsageFetcher,
|
||||
ProviderUsageFetcherFactoryOptions,
|
||||
ProviderUsageFetcherManifestEntry,
|
||||
} from "./provider.js";
|
||||
import { ClaudeQuotaProvider } from "./providers/claude.js";
|
||||
import { CodexQuotaProvider } from "./providers/codex.js";
|
||||
import { CopilotQuotaProvider } from "./providers/copilot.js";
|
||||
import { CursorQuotaProvider } from "./providers/cursor.js";
|
||||
import { GrokQuotaProvider } from "./providers/grok.js";
|
||||
import { KimiQuotaProvider } from "./providers/kimi.js";
|
||||
import { ZaiQuotaProvider } from "./providers/zai.js";
|
||||
|
||||
export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry[] = [
|
||||
{
|
||||
providerId: "claude",
|
||||
create: (options) =>
|
||||
new ClaudeQuotaProvider({
|
||||
logger: options.logger,
|
||||
fetch: options.fetch,
|
||||
}),
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
create: (options) =>
|
||||
new CodexQuotaProvider({
|
||||
logger: options.logger,
|
||||
fetch: options.fetch,
|
||||
}),
|
||||
},
|
||||
{
|
||||
providerId: "copilot",
|
||||
create: (options) => new CopilotQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "cursor",
|
||||
create: (options) => new CursorQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "zai",
|
||||
create: (options) => new ZaiQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "grok",
|
||||
create: (options) => new GrokQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "kimi",
|
||||
create: (options) => new KimiQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
];
|
||||
|
||||
export function createProviderUsageFetchers(
|
||||
options: ProviderUsageFetcherFactoryOptions,
|
||||
): ProviderUsageFetcher[] {
|
||||
return PROVIDER_USAGE_FETCHERS.map((entry) => entry.create(options));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Logger } from "pino";
|
||||
import type { ProviderUsage } from "../../server/messages.js";
|
||||
|
||||
export type ProviderApiFetch = typeof fetch;
|
||||
|
||||
export interface ProviderUsageFetcher {
|
||||
readonly providerId: string;
|
||||
readonly displayName: string;
|
||||
fetchUsage(): Promise<ProviderUsage>;
|
||||
}
|
||||
|
||||
export interface ProviderUsageFetcherFactoryOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export interface ProviderUsageFetcherManifestEntry {
|
||||
readonly providerId: string;
|
||||
create(options: ProviderUsageFetcherFactoryOptions): ProviderUsageFetcher;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageWindow,
|
||||
} from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
} from "../usage.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
|
||||
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
||||
const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
|
||||
const ClaudeCredentialsSchema = z.object({
|
||||
claudeAiOauth: z
|
||||
.object({
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
subscriptionType: z.string().optional(),
|
||||
rateLimitTier: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ClaudeUsageWindowSchema = z.object({
|
||||
utilization: ApiNumberSchema,
|
||||
resets_at: z.string().optional(),
|
||||
});
|
||||
|
||||
const ClaudeUsageResponseSchema = z.object({
|
||||
five_hour: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day_opus: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day_omelette: ClaudeUsageWindowSchema.nullish(),
|
||||
extra_usage: z
|
||||
.object({
|
||||
is_enabled: z.boolean().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const ClaudeTokenRefreshSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
|
||||
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
|
||||
type ClaudeTokenRefresh = z.infer<typeof ClaudeTokenRefreshSchema>;
|
||||
|
||||
interface ClaudeCredentialRecord {
|
||||
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
interface ClaudeQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
claudeHome?: string;
|
||||
claudeKeychainReader?: () => Promise<unknown | null>;
|
||||
platform?: typeof process.platform;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function buildClaudePlan(
|
||||
subscriptionType: string | undefined,
|
||||
rateLimitTier: string | undefined,
|
||||
): string | null {
|
||||
if (!subscriptionType) return null;
|
||||
const label = subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
|
||||
const tier = rateLimitTier?.split("_").pop();
|
||||
return tier ? `${label} ${tier}` : label;
|
||||
}
|
||||
|
||||
async function readClaudeKeychainCredentials(): Promise<unknown | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"security",
|
||||
["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"],
|
||||
{ timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS },
|
||||
);
|
||||
const raw = stdout.trim();
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "claude";
|
||||
readonly displayName = "Claude";
|
||||
|
||||
private readonly claudeHome: string;
|
||||
private readonly readKeychainCredentials: () => Promise<unknown | null>;
|
||||
private readonly platform: typeof process.platform;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: ClaudeQuotaProviderOptions) {
|
||||
this.claudeHome =
|
||||
options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude");
|
||||
this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials;
|
||||
this.platform = options.platform ?? process.platform;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const credentials = await this.readCredentials();
|
||||
if (!credentials) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const { oauth, filePath } = credentials;
|
||||
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
|
||||
let resp = await this.callClaudeApi(oauth.accessToken);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
if (!filePath || !oauth.refreshToken) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const refreshed = await this.refreshClaudeToken(oauth.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
await this.saveClaudeCredentials(filePath, {
|
||||
...oauth,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? oauth.refreshToken,
|
||||
});
|
||||
|
||||
resp = await this.callClaudeApi(refreshed.access_token);
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
}
|
||||
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
if (resp.five_hour) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "five_hour",
|
||||
label: "Session",
|
||||
utilizationPct: resp.five_hour.utilization,
|
||||
resetsAt: resp.five_hour.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
utilizationPct: resp.seven_day.utilization,
|
||||
resetsAt: resp.seven_day.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day_opus) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly_opus",
|
||||
label: "Weekly · Opus",
|
||||
utilizationPct: resp.seven_day_opus.utilization,
|
||||
resetsAt: resp.seven_day_opus.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day_omelette) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly_omelette",
|
||||
label: "Weekly · Omelette",
|
||||
utilizationPct: resp.seven_day_omelette.utilization,
|
||||
resetsAt: resp.seven_day_omelette.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const details: ProviderUsageDetail[] = [];
|
||||
const extraUsageEnabled = resp.extra_usage?.is_enabled;
|
||||
if (extraUsageEnabled !== undefined) {
|
||||
details.push({
|
||||
id: "extra_usage",
|
||||
label: "Extra usage",
|
||||
value: extraUsageEnabled ? "Enabled" : "Disabled",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: plan,
|
||||
windows,
|
||||
balances: [],
|
||||
details,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readCredentials(): Promise<ClaudeCredentialRecord | null> {
|
||||
const credPath = join(this.claudeHome, ".credentials.json");
|
||||
|
||||
if (existsSync(credPath)) {
|
||||
try {
|
||||
const creds = ClaudeCredentialsSchema.parse(
|
||||
JSON.parse(await fs.readFile(credPath, "utf8")),
|
||||
);
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth?.accessToken) {
|
||||
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: credPath };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the macOS Keychain below.
|
||||
}
|
||||
}
|
||||
|
||||
if (this.platform === "darwin") {
|
||||
const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
|
||||
const oauth = creds.success ? creds.data.claudeAiOauth : undefined;
|
||||
if (oauth?.accessToken) {
|
||||
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: null };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"anthropic-beta": CLAUDE_OAUTH_BETA,
|
||||
},
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
|
||||
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
|
||||
return ClaudeUsageResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async refreshClaudeToken(refreshToken: string): Promise<ClaudeTokenRefresh | null> {
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLAUDE_CLIENT_ID,
|
||||
scope: "user:profile user:inference user:sessions:claude_code user:mcp_servers",
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return ClaudeTokenRefreshSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async saveClaudeCredentials(
|
||||
credPath: string,
|
||||
oauth: ClaudeCredentials["claudeAiOauth"],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = ClaudeCredentialsSchema.parse(
|
||||
JSON.parse(await fs.readFile(credPath, "utf8")),
|
||||
);
|
||||
existing.claudeAiOauth = oauth;
|
||||
await fs.writeFile(credPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal; Claude Code can refresh again on its own next time.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageWindow,
|
||||
} from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
} from "../usage.js";
|
||||
|
||||
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
|
||||
const CodexAuthSchema = z.object({
|
||||
tokens: z
|
||||
.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
account_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const CodexWindowSchema = z.object({
|
||||
used_percent: ApiNumberSchema.optional(),
|
||||
reset_at: ApiNumberSchema.optional(),
|
||||
});
|
||||
|
||||
const CodexUsageResponseSchema = z.object({
|
||||
plan_type: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
primary_window: CodexWindowSchema.nullish(),
|
||||
secondary_window: CodexWindowSchema.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
code_review_rate_limit: z
|
||||
.object({
|
||||
primary_window: CodexWindowSchema.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
credits: z
|
||||
.object({
|
||||
has_credits: z.boolean().optional(),
|
||||
unlimited: z.boolean().optional(),
|
||||
balance: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const CodexTokenRefreshSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type CodexAuth = z.infer<typeof CodexAuthSchema>;
|
||||
type CodexWindow = z.infer<typeof CodexWindowSchema>;
|
||||
type CodexUsageResponse = z.infer<typeof CodexUsageResponseSchema>;
|
||||
type CodexTokenRefresh = z.infer<typeof CodexTokenRefreshSchema>;
|
||||
|
||||
interface CodexAuthRecord {
|
||||
auth: CodexAuth;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CodexQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
codexHome?: string;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function codexWindow(
|
||||
window: CodexWindow | null | undefined,
|
||||
): { usedPct: number; resetsAt: string | null } | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
usedPct: window.used_percent ?? 0,
|
||||
resetsAt: window.reset_at != null ? new Date(window.reset_at * 1000).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export class CodexQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "codex";
|
||||
readonly displayName = "Codex";
|
||||
|
||||
private readonly codexHome: string;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CodexQuotaProviderOptions) {
|
||||
this.codexHome = options.codexHome || process.env["CODEX_HOME"] || join(homedir(), ".codex");
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const authRecord = await this.readCodexAuth();
|
||||
const auth = authRecord?.auth;
|
||||
const accessToken = auth?.tokens?.access_token;
|
||||
if (!authRecord || !auth || !accessToken) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const { refresh_token, account_id } = auth.tokens ?? {};
|
||||
let resp = await this.callCodexApi(accessToken, account_id);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
if (!refresh_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
const refreshed = await this.refreshCodexToken(refresh_token);
|
||||
if (!refreshed?.access_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
await this.saveCodexAuth(authRecord.path, auth, refreshed);
|
||||
resp = await this.callCodexApi(refreshed.access_token, account_id);
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
}
|
||||
|
||||
return this.toUsage(resp);
|
||||
}
|
||||
|
||||
private toUsage(resp: CodexUsageResponse): ProviderUsage {
|
||||
const session = codexWindow(resp.rate_limit?.primary_window);
|
||||
const weekly = codexWindow(resp.rate_limit?.secondary_window);
|
||||
const codeReview = codexWindow(resp.code_review_rate_limit?.primary_window);
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
|
||||
if (session) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "session",
|
||||
label: "Session",
|
||||
utilizationPct: session.usedPct,
|
||||
resetsAt: session.resetsAt,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (weekly) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
utilizationPct: weekly.usedPct,
|
||||
resetsAt: weekly.resetsAt,
|
||||
tone: weekly.usedPct >= 70 ? "warning" : "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (codeReview) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "code_review",
|
||||
label: "Code review",
|
||||
utilizationPct: codeReview.usedPct,
|
||||
resetsAt: codeReview.resetsAt,
|
||||
tone: codeReview.usedPct >= 70 ? "warning" : "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (resp.credits?.balance !== undefined) {
|
||||
balances.push({
|
||||
id: "credits",
|
||||
label: "Credits",
|
||||
remaining: resp.credits.balance,
|
||||
unit: "usd",
|
||||
tone: balanceToneFromRemaining(resp.credits.balance),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: resp.plan_type ?? null,
|
||||
windows,
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readCodexAuth(): Promise<CodexAuthRecord | null> {
|
||||
const candidates = [
|
||||
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
|
||||
join(homedir(), ".config", "codex", "auth.json"),
|
||||
join(this.codexHome, "auth.json"),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
if (auth.tokens?.access_token) return { auth, path };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async callCodexApi(
|
||||
token: string,
|
||||
accountId?: string,
|
||||
): Promise<CodexUsageResponse | "NEEDS_AUTH"> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
};
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
|
||||
if (!res.ok) throw new Error(`Codex usage API returned ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (text.trim().startsWith("<")) return "NEEDS_AUTH";
|
||||
return CodexUsageResponseSchema.parse(JSON.parse(text));
|
||||
}
|
||||
|
||||
private async refreshCodexToken(refreshToken: string): Promise<CodexTokenRefresh | null> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return CodexTokenRefreshSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async saveCodexAuth(
|
||||
authPath: string,
|
||||
original: CodexAuth,
|
||||
refreshed: CodexTokenRefresh,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const updated: CodexAuth = {
|
||||
...original,
|
||||
tokens: {
|
||||
...original.tokens,
|
||||
access_token: refreshed.access_token ?? original.tokens?.access_token,
|
||||
refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal; the next call can refresh again.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const CopilotUsageResponseSchema = z.object({
|
||||
copilot_plan: ApiOptionalStringSchema,
|
||||
quota_reset_date: ApiOptionalStringSchema,
|
||||
});
|
||||
|
||||
interface CopilotQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
async function readGithubCliToken(): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml"));
|
||||
}
|
||||
candidates.push(join(homedir(), ".config", "gh", "hosts.yml"));
|
||||
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path, "utf8");
|
||||
const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/);
|
||||
if (match?.[1]) return match[1];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class CopilotQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "copilot";
|
||||
readonly displayName = "GitHub Copilot";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CopilotQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["COPILOT_TOKEN"] ||
|
||||
process.env["GITHUB_TOKEN"] ||
|
||||
process.env["GITHUB_PAT"] ||
|
||||
(await readGithubCliToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"Editor-Version": "vscode/1.96.2",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"X-Github-Api-Version": "2025-04-01",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Copilot usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = CopilotUsageResponseSchema.parse(await res.json());
|
||||
const details: ProviderUsageDetail[] = resp.quota_reset_date
|
||||
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: resp.copilot_plan || null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNullableNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
toIsoStringOrNull,
|
||||
unavailableUsage,
|
||||
} from "../usage.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
|
||||
|
||||
const CursorBillingCycleTimestampSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
|
||||
z.union([z.string(), z.number()]).nullable(),
|
||||
);
|
||||
|
||||
const CursorUsageResponseSchema = z.object({
|
||||
planUsage: z
|
||||
.object({
|
||||
totalSpend: ApiNullableNumberSchema,
|
||||
includedSpend: ApiNullableNumberSchema,
|
||||
bonusSpend: ApiNullableNumberSchema,
|
||||
remaining: ApiNullableNumberSchema,
|
||||
limit: ApiNullableNumberSchema,
|
||||
})
|
||||
.nullish(),
|
||||
billingCycleStart: CursorBillingCycleTimestampSchema,
|
||||
billingCycleEnd: CursorBillingCycleTimestampSchema,
|
||||
});
|
||||
|
||||
const CursorAuthStatusSchema = z.object({
|
||||
accessToken: z.string().optional(),
|
||||
});
|
||||
|
||||
type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
|
||||
|
||||
interface CursorQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function parseCursorBillingCycleTimestamp(
|
||||
value: CursorUsageResponse["billingCycleStart"],
|
||||
): string | null {
|
||||
if (value === null) return null;
|
||||
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const numeric = Number(raw);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const timestampMs = Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric;
|
||||
return toIsoStringOrNull(timestampMs);
|
||||
}
|
||||
|
||||
return toIsoStringOrNull(new Date(raw).getTime());
|
||||
}
|
||||
|
||||
function centsToDollars(value: number | null): number | null {
|
||||
return value === null ? null : value / 100;
|
||||
}
|
||||
|
||||
async function readCursorTokenFromSqlite(): Promise<string | null> {
|
||||
const dbPaths: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
}
|
||||
dbPaths.push(
|
||||
join(
|
||||
homedir(),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Cursor",
|
||||
"User",
|
||||
"globalStorage",
|
||||
"state.vscdb",
|
||||
),
|
||||
);
|
||||
dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
|
||||
for (const path of dbPaths) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"sqlite3",
|
||||
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
|
||||
{ timeout: CURSOR_SQLITE_TIMEOUT_MS },
|
||||
);
|
||||
if (stdout) {
|
||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
|
||||
if (parsed.accessToken) return parsed.accessToken;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class CursorQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "cursor";
|
||||
readonly displayName = "Cursor";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CursorQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||
process.env["CURSOR_TOKEN"] ||
|
||||
(await readCursorTokenFromSqlite());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Connect-Protocol-Version": "1",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Cursor usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = CursorUsageResponseSchema.parse(await res.json());
|
||||
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (resp.planUsage) {
|
||||
const totalSpend = centsToDollars(resp.planUsage.totalSpend);
|
||||
const remaining = centsToDollars(resp.planUsage.remaining);
|
||||
const limit = centsToDollars(resp.planUsage.limit);
|
||||
balances.push({
|
||||
id: "plan_usage",
|
||||
label: "Plan usage",
|
||||
used: totalSpend,
|
||||
remaining,
|
||||
limit,
|
||||
unit: "usd",
|
||||
resetsAt: billingCycleEnd,
|
||||
tone: balanceToneFromRemaining(remaining),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
} from "../usage.js";
|
||||
|
||||
const GrokUsageResponseSchema = z.object({
|
||||
config: z
|
||||
.object({
|
||||
monthlyLimit: z
|
||||
.object({
|
||||
val: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
usage: z
|
||||
.object({
|
||||
creditUsage: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const GrokAuthSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GrokQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "grok";
|
||||
readonly displayName = "Grok";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: GrokQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Grok usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||
const monthlyLimit = resp.config?.monthlyLimit?.val ?? null;
|
||||
const creditUsage = resp.usage?.creditUsage ?? null;
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (monthlyLimit !== null || creditUsage !== null) {
|
||||
const remaining =
|
||||
monthlyLimit !== null && creditUsage !== null
|
||||
? Math.max(0, monthlyLimit - creditUsage)
|
||||
: null;
|
||||
balances.push({
|
||||
id: "monthly_credits",
|
||||
label: "Monthly credits",
|
||||
used: creditUsage,
|
||||
remaining,
|
||||
limit: monthlyLimit,
|
||||
unit: "credits",
|
||||
tone: balanceToneFromRemaining(remaining),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readGrokToken(): Promise<string | null> {
|
||||
const path = join(homedir(), ".grok", "auth.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
return auth.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const KimiUsageResponseSchema = z.object({
|
||||
usage: z
|
||||
.object({
|
||||
limit: ApiOptionalStringSchema,
|
||||
remaining: ApiOptionalStringSchema,
|
||||
resetTime: ApiOptionalStringSchema,
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const KimiAuthSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
});
|
||||
|
||||
interface KimiQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class KimiQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "kimi";
|
||||
readonly displayName = "Kimi";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: KimiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"] || (await this.readKimiToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.kimi.com/coding/v1/usages", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Kimi usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = KimiUsageResponseSchema.parse(await res.json());
|
||||
const limit = resp.usage?.limit === undefined ? null : Number(resp.usage.limit);
|
||||
const remaining = resp.usage?.remaining === undefined ? null : Number(resp.usage.remaining);
|
||||
const hasFiniteLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
|
||||
const hasFiniteRemaining = typeof remaining === "number" && Number.isFinite(remaining);
|
||||
const usedPct =
|
||||
hasFiniteLimit && hasFiniteRemaining
|
||||
? Math.max(0, Math.min(100, ((limit - remaining) / limit) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [
|
||||
{
|
||||
id: "coding_usage",
|
||||
label: "Coding usage",
|
||||
usedPct,
|
||||
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
||||
resetsAt: resp.usage?.resetTime ?? null,
|
||||
tone: "ok",
|
||||
},
|
||||
],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readKimiToken(): Promise<string | null> {
|
||||
const path = join(homedir(), ".kimi", "credentials", "kimi-code.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
return credentials.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const ZaiUsageResponseSchema = z.object({
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
productName: ApiOptionalStringSchema,
|
||||
status: ApiOptionalStringSchema,
|
||||
purchaseTime: ApiOptionalStringSchema,
|
||||
valid: ApiOptionalStringSchema,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface ZaiQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class ZaiQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "zai";
|
||||
readonly displayName = "Z.ai";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: ZaiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"];
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://api.z.ai/api/biz/subscription/list",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Z.ai usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = ZaiUsageResponseSchema.parse(await res.json());
|
||||
const sub = resp.data?.[0];
|
||||
if (!sub) return unavailableUsage(this);
|
||||
|
||||
const details: ProviderUsageDetail[] = [];
|
||||
if (sub.status) details.push({ id: "status", label: "Status", value: sub.status });
|
||||
if (sub.valid) details.push({ id: "valid", label: "Valid", value: sub.valid });
|
||||
if (sub.purchaseTime) {
|
||||
details.push({ id: "purchase_time", label: "Purchased", value: sub.purchaseTime });
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: sub.productName || null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderUsage } from "../../server/messages.js";
|
||||
import type { ProviderUsageFetcher } from "./provider.js";
|
||||
import { ClaudeQuotaProvider } from "./providers/claude.js";
|
||||
import { CodexQuotaProvider } from "./providers/codex.js";
|
||||
import { CopilotQuotaProvider } from "./providers/copilot.js";
|
||||
import { CursorQuotaProvider } from "./providers/cursor.js";
|
||||
import { GrokQuotaProvider } from "./providers/grok.js";
|
||||
import { KimiQuotaProvider } from "./providers/kimi.js";
|
||||
import { ZaiQuotaProvider } from "./providers/zai.js";
|
||||
import { ProviderUsageService } from "./service.js";
|
||||
|
||||
function writeClaudeCredentials(
|
||||
dir: string,
|
||||
accessToken: string,
|
||||
refreshToken = "rt_test",
|
||||
subscriptionType = "pro",
|
||||
rateLimitTier = "default_1x",
|
||||
): void {
|
||||
writeFileSync(
|
||||
join(dir, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken, refreshToken, subscriptionType, rateLimitTier },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_codex"): void {
|
||||
writeFileSync(
|
||||
join(dir, "auth.json"),
|
||||
JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken } }),
|
||||
);
|
||||
}
|
||||
|
||||
function makeClaudeResponse(
|
||||
overrides: Partial<{
|
||||
five_hour: { utilization: number | string; resets_at: string };
|
||||
seven_day: { utilization: number | string; resets_at: string };
|
||||
seven_day_opus: { utilization: number | string; resets_at: string };
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
five_hour: { utilization: 11, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" },
|
||||
seven_day_opus: { utilization: 0.5, resets_at: "2026-06-04T00:00:00Z" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCodexResponse(overrides: object = {}) {
|
||||
return {
|
||||
plan_type: "plus",
|
||||
email: "user@example.com",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 42, reset_at: 1_748_812_800 },
|
||||
secondary_window: { used_percent: 8, reset_at: 1_749_072_000 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
|
||||
return vi.fn(async (url: RequestInfo | URL) => {
|
||||
const key = url.toString();
|
||||
const handler = handlers.get(key);
|
||||
if (!handler) throw new Error(`Unmocked fetch: ${key}`);
|
||||
return handler();
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: () => logger,
|
||||
};
|
||||
return logger as never;
|
||||
}
|
||||
|
||||
function usageFetcher(usage: ProviderUsage): ProviderUsageFetcher {
|
||||
return {
|
||||
providerId: usage.providerId,
|
||||
displayName: usage.displayName,
|
||||
fetchUsage: async () => usage,
|
||||
};
|
||||
}
|
||||
|
||||
function findProvider(result: { providers: ProviderUsage[] }, providerId: string): ProviderUsage {
|
||||
const provider = result.providers.find((candidate) => candidate.providerId === providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`Missing provider ${providerId}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("ProviderUsageService", () => {
|
||||
it("returns arbitrary registered providers and windows as normalized usage data", async () => {
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
usageFetcher({
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
resetsAt: "2026-07-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.listUsage()).resolves.toEqual({
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
resetsAt: "2026-07-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("caches usage until forced to refresh", async () => {
|
||||
let now = Date.parse("2026-06-19T00:00:00.000Z");
|
||||
let calls = 0;
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => now,
|
||||
cacheTtlMs: 60_000,
|
||||
fetchers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
fetchUsage: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: calls }],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await service.listUsage();
|
||||
now += 30_000;
|
||||
const cached = await service.listUsage();
|
||||
const refreshed = await service.listUsage({ forceRefresh: true });
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(cached).toBe(first);
|
||||
expect(refreshed.providers[0]?.windows[0]?.usedPct).toBe(2);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent cache misses", async () => {
|
||||
let calls = 0;
|
||||
let resolveUsage: ((usage: ProviderUsage) => void) | null = null;
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
fetchUsage: () => {
|
||||
calls += 1;
|
||||
return new Promise<ProviderUsage>((resolve) => {
|
||||
resolveUsage = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = service.listUsage();
|
||||
const second = service.listUsage();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
resolveUsage?.({
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 12 }],
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBe(secondResult);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("isolates one provider error without dropping other providers", async () => {
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
fetchUsage: async () => {
|
||||
throw new Error("Claude auth expired");
|
||||
},
|
||||
},
|
||||
usageFetcher({
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.listUsage()).resolves.toEqual({
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("real provider usage fetchers", () => {
|
||||
let claudeHome: string;
|
||||
let codexHome: string;
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
claudeHome = mkdtempSync(join(tmpdir(), "usage-test-claude-"));
|
||||
codexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-"));
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-test-home-"));
|
||||
fetchApi = mockFetch(new Map());
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
|
||||
for (const key of [
|
||||
"APPDATA",
|
||||
"COPILOT_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_PAT",
|
||||
"CURSOR_ACCESS_TOKEN",
|
||||
"CURSOR_TOKEN",
|
||||
"ZAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"GROK_API_KEY",
|
||||
"GROK_TOKEN",
|
||||
"KIMI_TOKEN",
|
||||
"KIMI_API_KEY",
|
||||
"CODEX_HOME",
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key in originalEnv) {
|
||||
process.env[key] = originalEnv[key];
|
||||
}
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function service(
|
||||
options: { platform?: typeof process.platform; keychain?: () => Promise<unknown | null> } = {},
|
||||
) {
|
||||
const logger = createLogger();
|
||||
const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) =>
|
||||
fetchApi(url, init)) as typeof fetch;
|
||||
return new ProviderUsageService({
|
||||
logger,
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
new ClaudeQuotaProvider({
|
||||
logger,
|
||||
claudeHome,
|
||||
claudeKeychainReader: options.keychain ?? (async () => null),
|
||||
platform: options.platform,
|
||||
fetch: fetchThroughTestDouble,
|
||||
}),
|
||||
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
|
||||
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
],
|
||||
cacheTtlMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
() =>
|
||||
jsonResponse(
|
||||
makeClaudeResponse({
|
||||
five_hour: { utilization: "11", resets_at: "2026-06-01T21:00:00Z" },
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
const claude = findProvider(result, "claude");
|
||||
|
||||
expect(claude).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "Pro 1x",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "five_hour", usedPct: 11 }),
|
||||
expect.objectContaining({ id: "weekly", usedPct: 1 }),
|
||||
expect.objectContaining({ id: "weekly_opus", usedPct: 0.5 }),
|
||||
]),
|
||||
});
|
||||
expect(fetchApi).toHaveBeenCalledWith(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unavailable Claude usage when credentials are missing", async () => {
|
||||
fetchApi = vi.fn() as never;
|
||||
|
||||
const result = await service().listUsage();
|
||||
const claude = findProvider(result, "claude");
|
||||
|
||||
expect(claude.status).toBe("unavailable");
|
||||
expect(fetchApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes Claude access tokens on 401 and retries", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_expired", "rt_valid");
|
||||
let usageCalls = 0;
|
||||
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
|
||||
const endpoint = url.toString();
|
||||
if (endpoint === "https://api.anthropic.com/api/oauth/usage") {
|
||||
usageCalls += 1;
|
||||
if (usageCalls === 1) return new Response(null, { status: 401 });
|
||||
return jsonResponse(makeClaudeResponse());
|
||||
}
|
||||
if (endpoint === "https://platform.claude.com/v1/oauth/token") {
|
||||
return jsonResponse({ access_token: "at_refreshed", refresh_token: "rt_new" });
|
||||
}
|
||||
throw new Error(`Unmocked: ${endpoint}`);
|
||||
}) as never;
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("available");
|
||||
expect(usageCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("returns unavailable Claude usage when 401 persists after refresh", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_bad", "rt_bad");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
["https://api.anthropic.com/api/oauth/usage", () => new Response(null, { status: 401 })],
|
||||
[
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
() => jsonResponse({ access_token: "at_still_bad", refresh_token: "rt_still_bad" }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("does not refresh Claude tokens read from the macOS Keychain", async () => {
|
||||
const usageFetch = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
fetchApi = usageFetch as never;
|
||||
|
||||
const result = await service({
|
||||
platform: "darwin",
|
||||
keychain: async () => ({
|
||||
claudeAiOauth: {
|
||||
accessToken: "at_expired",
|
||||
refreshToken: "rt_valid",
|
||||
},
|
||||
}),
|
||||
}).listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("unavailable");
|
||||
expect(usageFetch).toHaveBeenCalledTimes(1);
|
||||
expect(usageFetch).toHaveBeenCalledWith(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer at_expired" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches Codex windows and coerces string credit balances", async () => {
|
||||
writeCodexAuth(codexHome, "at_codex_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
() =>
|
||||
jsonResponse(
|
||||
makeCodexResponse({
|
||||
code_review_rate_limit: null,
|
||||
credits: { balance: "0" },
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
const codex = findProvider(result, "codex");
|
||||
|
||||
expect(codex).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "plus",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "session", usedPct: 42 }),
|
||||
expect.objectContaining({ id: "weekly", usedPct: 8 }),
|
||||
]),
|
||||
balances: [expect.objectContaining({ id: "credits", remaining: 0 })],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a Codex HTML usage response as auth failure", async () => {
|
||||
writeCodexAuth(codexHome, "at_codex_stale");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
() => new Response("<html>Login</html>", { status: 200 }),
|
||||
],
|
||||
["https://auth.openai.com/oauth/token", () => new Response(null, { status: 401 })],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "codex").status).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("persists refreshed Codex tokens to the auth file that was read", async () => {
|
||||
const alternateCodexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-alt-"));
|
||||
process.env["CODEX_HOME"] = alternateCodexHome;
|
||||
writeFileSync(join(alternateCodexHome, "auth.json"), JSON.stringify({ tokens: {} }));
|
||||
writeCodexAuth(codexHome, "at_codex_stale", "rt_codex_valid");
|
||||
|
||||
let usageCalls = 0;
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
() => {
|
||||
usageCalls += 1;
|
||||
if (usageCalls === 1) return new Response(null, { status: 401 });
|
||||
return jsonResponse(makeCodexResponse());
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://auth.openai.com/oauth/token",
|
||||
() => jsonResponse({ access_token: "at_codex_fresh", refresh_token: "rt_codex_fresh" }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await service().listUsage();
|
||||
|
||||
const refreshedAuth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8"));
|
||||
const untouchedAuth = JSON.parse(readFileSync(join(alternateCodexHome, "auth.json"), "utf8"));
|
||||
expect(findProvider(result, "codex").status).toBe("available");
|
||||
expect(refreshedAuth.tokens.access_token).toBe("at_codex_fresh");
|
||||
expect(refreshedAuth.tokens.refresh_token).toBe("rt_codex_fresh");
|
||||
expect(untouchedAuth.tokens.access_token).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(alternateCodexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fetches Copilot usage from COPILOT_TOKEN", async () => {
|
||||
process.env["COPILOT_TOKEN"] = "copilot_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
() =>
|
||||
jsonResponse({
|
||||
copilot_plan: "business",
|
||||
quota_reset_date: "2026-07-01T00:00:00Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const copilot = findProvider(await service().listUsage(), "copilot");
|
||||
|
||||
expect(copilot).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "business",
|
||||
details: [{ id: "reset", label: "Quota reset", value: "2026-07-01T00:00:00Z" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Cursor usage and normalizes malformed billing dates to null", async () => {
|
||||
process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
() =>
|
||||
jsonResponse({
|
||||
planUsage: {
|
||||
totalSpend: "1500",
|
||||
includedSpend: "1000",
|
||||
bonusSpend: "500",
|
||||
remaining: "2500",
|
||||
limit: "4000",
|
||||
},
|
||||
billingCycleStart: "2026-01-14T12:42:14.000Z",
|
||||
billingCycleEnd: "not-a-date",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const cursor = findProvider(await service().listUsage(), "cursor");
|
||||
|
||||
expect(cursor).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "plan_usage",
|
||||
used: 15,
|
||||
remaining: 25,
|
||||
limit: 40,
|
||||
resetsAt: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
|
||||
process.env["ZAI_API_KEY"] = "zai_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.z.ai/api/biz/subscription/list",
|
||||
() =>
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
productName: "GLM Coding Max",
|
||||
status: "VALID",
|
||||
purchaseTime: "2026-01-12 16:55:13",
|
||||
valid: "2026-02-12 16:55:13-2026-03-12 16:55:13",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(zai).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "GLM Coding Max",
|
||||
details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]),
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Grok usage and preserves zero values", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: { monthlyLimit: { val: 0 } },
|
||||
usage: { creditUsage: 0 },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 0,
|
||||
remaining: 0,
|
||||
limit: 0,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Kimi usage from KIMI_TOKEN", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.kimi.com/coding/v1/usages",
|
||||
() =>
|
||||
jsonResponse({
|
||||
usage: {
|
||||
limit: "100",
|
||||
remaining: "74",
|
||||
resetTime: "2026-02-11T17:32:50Z",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const kimi = findProvider(await service().listUsage(), "kimi");
|
||||
|
||||
expect(kimi).toMatchObject({
|
||||
status: "available",
|
||||
windows: [
|
||||
expect.objectContaining({
|
||||
id: "coding_usage",
|
||||
usedPct: 26,
|
||||
remainingPct: 74,
|
||||
resetsAt: "2026-02-11T17:32:50Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Logger } from "pino";
|
||||
import type { ProviderUsage } from "../../server/messages.js";
|
||||
import { createProviderUsageFetchers } from "./manifest.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "./provider.js";
|
||||
import { unavailableUsage } from "./usage.js";
|
||||
|
||||
export interface ProviderUsageServiceOptions {
|
||||
logger: Logger;
|
||||
fetchers?: ProviderUsageFetcher[];
|
||||
fetch?: ProviderApiFetch;
|
||||
cacheTtlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface ProviderUsageListResult {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class ProviderUsageService {
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchers: ProviderUsageFetcher[];
|
||||
private readonly cacheTtlMs: number;
|
||||
private readonly now: () => number;
|
||||
private cached: { fetchedAtMs: number; result: ProviderUsageListResult } | null = null;
|
||||
private inFlight: Promise<ProviderUsageListResult> | null = null;
|
||||
|
||||
constructor(options: ProviderUsageServiceOptions) {
|
||||
this.logger = options.logger.child({ module: "provider-usage-service" });
|
||||
this.fetchers =
|
||||
options.fetchers ??
|
||||
createProviderUsageFetchers({
|
||||
logger: this.logger,
|
||||
fetch: options.fetch,
|
||||
});
|
||||
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async listUsage(options?: { forceRefresh?: boolean }): Promise<ProviderUsageListResult> {
|
||||
const nowMs = this.now();
|
||||
if (
|
||||
!options?.forceRefresh &&
|
||||
this.cached &&
|
||||
nowMs - this.cached.fetchedAtMs < this.cacheTtlMs
|
||||
) {
|
||||
return this.cached.result;
|
||||
}
|
||||
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
|
||||
const request = this.fetchFreshUsage(nowMs);
|
||||
this.inFlight = request;
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (this.inFlight === request) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFreshUsage(nowMs: number): Promise<ProviderUsageListResult> {
|
||||
const settled = await Promise.allSettled(this.fetchers.map((fetcher) => fetcher.fetchUsage()));
|
||||
const providers = settled.map((result, index) => {
|
||||
const fetcher = this.fetchers[index];
|
||||
if (result.status === "fulfilled") {
|
||||
return result.value;
|
||||
}
|
||||
this.logger.debug(
|
||||
{ err: result.reason, providerId: fetcher.providerId },
|
||||
"Provider usage fetch failed",
|
||||
);
|
||||
return unavailableUsage({
|
||||
providerId: fetcher.providerId,
|
||||
displayName: fetcher.displayName,
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
});
|
||||
});
|
||||
|
||||
const result = { fetchedAt: new Date(nowMs).toISOString(), providers };
|
||||
this.cached = { fetchedAtMs: nowMs, result };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageWindow,
|
||||
} from "../../server/messages.js";
|
||||
import type { ProviderApiFetch } from "./provider.js";
|
||||
|
||||
const PROVIDER_HTTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const ApiNumberSchema = z.coerce.number().finite();
|
||||
export const ApiNullableNumberSchema = z.preprocess(
|
||||
(value) => (value == null ? null : value),
|
||||
ApiNumberSchema.nullable(),
|
||||
);
|
||||
export const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
export function fetchProviderApi(
|
||||
fetchApi: ProviderApiFetch,
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetchApi(input, {
|
||||
...init,
|
||||
signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
export function unavailableUsage(provider: {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
error?: string | null;
|
||||
}): ProviderUsage {
|
||||
return {
|
||||
providerId: provider.providerId,
|
||||
displayName: provider.displayName,
|
||||
status: provider.error ? "error" : "unavailable",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: provider.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function windowFromUsedPct(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
utilizationPct: number | null | undefined;
|
||||
resetsAt?: string | null;
|
||||
tone?: ProviderUsageWindow["tone"];
|
||||
}): ProviderUsageWindow {
|
||||
const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null;
|
||||
const window: ProviderUsageWindow = {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
usedPct,
|
||||
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
||||
resetsAt: input.resetsAt ?? null,
|
||||
};
|
||||
if (input.tone) {
|
||||
window.tone = input.tone;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
export function balanceToneFromRemaining(
|
||||
remaining: number | null | undefined,
|
||||
): ProviderUsageBalance["tone"] {
|
||||
if (typeof remaining !== "number") return "default";
|
||||
if (remaining <= 0) return "danger";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export function toIsoStringOrNull(timestampMs: number): string | null {
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
Reference in New Issue
Block a user