mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
refactor(usage): shape sources as fetch functions
Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { z, type ZodType } from "zod";
|
||||
import type { ZodType } from "zod";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface UsageWindow {
|
||||
@@ -108,29 +108,6 @@ export function usedPctOf(
|
||||
return (used / limit) * 100;
|
||||
}
|
||||
|
||||
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 type UsageApiFetch = typeof fetch;
|
||||
|
||||
export function fetchProviderApi(
|
||||
fetchApi: UsageApiFetch,
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetchApi(input, {
|
||||
...init,
|
||||
signal: init.signal ?? AbortSignal.timeout(15_000),
|
||||
});
|
||||
}
|
||||
|
||||
export function unavailableUsage(accountKey = "default"): UsageReport {
|
||||
return {
|
||||
account: { key: accountKey },
|
||||
@@ -140,8 +117,3 @@ export function unavailableUsage(accountKey = "default"): UsageReport {
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function toIsoStringOrNull(timestampMs: number): string | null {
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { ClaudeQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,14 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (input) => {
|
||||
const parsed = inputSchema.parse(input);
|
||||
return new ClaudeQuotaProvider({
|
||||
logger: console,
|
||||
...("configDir" in parsed ? { configDir: parsed.configDir } : {}),
|
||||
...("accessToken" in parsed ? { accessToken: parsed.accessToken } : {}),
|
||||
}).fetchUsage();
|
||||
},
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "claude-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "claude-usage-source" }
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./usage.js";
|
||||
|
||||
const SERVICE = "Claude Code-credentials";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("claudeKeychainAccount", () => {
|
||||
it("uses a supported username verbatim", () => {
|
||||
expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit");
|
||||
});
|
||||
|
||||
it("falls back to claude-code-user when the username carries a rejected character", () => {
|
||||
expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user");
|
||||
});
|
||||
|
||||
it("reads the username from $USER when no account is given", () => {
|
||||
vi.stubEnv("USER", "ci-runner");
|
||||
expect(claudeKeychainAccount()).toBe("ci-runner");
|
||||
|
||||
vi.stubEnv("USER", "first.last@example.com");
|
||||
expect(claudeKeychainAccount()).toBe("claude-code-user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readClaudeKeychainCredentials", () => {
|
||||
it("looks the item up by account and stops there when it exists", async () => {
|
||||
const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } }));
|
||||
|
||||
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||
|
||||
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith([
|
||||
"find-generic-password",
|
||||
"-a",
|
||||
"claude-code-user",
|
||||
"-w",
|
||||
"-s",
|
||||
SERVICE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the account-less lookup when no item matches the account", async () => {
|
||||
const run = vi.fn(async (args: string[]) =>
|
||||
args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||
);
|
||||
|
||||
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||
|
||||
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } });
|
||||
expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]);
|
||||
});
|
||||
|
||||
it("falls through when the account item carries no access token", async () => {
|
||||
const run = vi.fn(async (args: string[]) =>
|
||||
args.includes("-a")
|
||||
? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } })
|
||||
: JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||
);
|
||||
|
||||
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||
|
||||
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } });
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is null when the Keychain holds no item at all", async () => {
|
||||
const run = vi.fn(async () => null);
|
||||
|
||||
expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("tries the next lookup when the item does not parse", async () => {
|
||||
const run = vi.fn(async (args: string[]) =>
|
||||
args.includes("-a")
|
||||
? "not-json"
|
||||
: JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||
);
|
||||
|
||||
expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({
|
||||
claudeAiOauth: { accessToken: "at_legacy" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fetchUsage } from "./usage.js";
|
||||
|
||||
function fixtureFetch(expectedToken: string): typeof fetch {
|
||||
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
expect(init?.headers).toMatchObject({ Authorization: `Bearer ${expectedToken}` });
|
||||
return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("Claude usage input forms", () => {
|
||||
it("reads only the selected configDir file", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }),
|
||||
);
|
||||
expect((await fetchUsage({ configDir: dir }, fixtureFetch("fixture-file"))).status).toBe(
|
||||
"available",
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("uses an accessToken without reading a file", async () => {
|
||||
expect(
|
||||
(await fetchUsage({ accessToken: "fixture-direct" }, fixtureFetch("fixture-direct"))).status,
|
||||
).toBe("available");
|
||||
});
|
||||
it("default discovery reads the credential file", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }),
|
||||
);
|
||||
vi.stubEnv("CLAUDE_HOME", dir);
|
||||
expect((await fetchUsage({}, fixtureFetch("fixture-default"))).status).toBe("available");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("stays unavailable when non-default configDir has no credentials", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-"));
|
||||
try {
|
||||
const fetchApi = vi.fn() as unknown as typeof fetch;
|
||||
expect((await fetchUsage({ configDir: dir }, fetchApi)).status).toBe("unavailable");
|
||||
expect(fetchApi).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Keychain token usage", () => {
|
||||
it("returns unavailable on 401 without attempting refresh", async () => {
|
||||
const run = vi.fn(async () =>
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: "fixture-expired", refreshToken: "fixture-refresh" },
|
||||
}),
|
||||
);
|
||||
const credentials = await readClaudeKeychainCredentials(run, "fixture-account");
|
||||
const token = (credentials as { claudeAiOauth: { accessToken: string } }).claudeAiOauth
|
||||
.accessToken;
|
||||
const fetchApi = vi.fn(
|
||||
async () => new Response(null, { status: 401 }),
|
||||
) as unknown as typeof fetch;
|
||||
const report = await fetchUsage({ accessToken: token }, fetchApi);
|
||||
expect(report.status).toBe("unavailable");
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,194 +1,601 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./usage.js";
|
||||
import { readFileSync, writeFileSync, 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 { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const SERVICE = "Claude Code-credentials";
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("claudeKeychainAccount", () => {
|
||||
it("uses a supported username verbatim", () => {
|
||||
expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit");
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("claude usage source", () => {
|
||||
let claudeHome: string;
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
claudeHome = mkdtempSync(join(tmpdir(), "usage-claude-"));
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = homeDir;
|
||||
process.env["CLAUDE_HOME"] = claudeHome;
|
||||
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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
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_model_opus", usedPct: 0.5 }),
|
||||
]),
|
||||
});
|
||||
expect(fetchApi).toHaveBeenCalledWith(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to claude-code-user when the username carries a rejected character", () => {
|
||||
expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user");
|
||||
it("accepts a null Claude resets_at when a window has no scheduled reset", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
() =>
|
||||
jsonResponse({
|
||||
five_hour: { utilization: 0, resets_at: null },
|
||||
seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
const claude = findProvider(result, "claude");
|
||||
|
||||
expect(claude).toMatchObject({
|
||||
status: "available",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "five_hour", usedPct: 0, resetsAt: null }),
|
||||
expect.objectContaining({ id: "weekly", usedPct: 1 }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the username from $USER when no account is given", () => {
|
||||
vi.stubEnv("USER", "ci-runner");
|
||||
expect(claudeKeychainAccount()).toBe("ci-runner");
|
||||
it("returns unavailable Claude usage when credentials are missing", async () => {
|
||||
fetchApi = vi.fn() as never;
|
||||
|
||||
vi.stubEnv("USER", "first.last@example.com");
|
||||
expect(claudeKeychainAccount()).toBe("claude-code-user");
|
||||
const result = await service().listUsage();
|
||||
const claude = findProvider(result, "claude");
|
||||
|
||||
expect(claude.status).toBe("unavailable");
|
||||
expect(fetchApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unavailable on 401 without refreshing or rewriting credentials", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_expired", "rt_valid");
|
||||
const credPath = join(claudeHome, ".credentials.json");
|
||||
const before = readFileSync(credPath, "utf8");
|
||||
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;
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
// The read-only fetcher must never hit the OAuth token endpoint.
|
||||
throw new Error(`Unmocked: ${endpoint}`);
|
||||
}) as never;
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("unavailable");
|
||||
expect(usageCalls).toBe(1);
|
||||
// The credentials file must be left untouched for the Claude CLI to own.
|
||||
expect(readFileSync(credPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readClaudeKeychainCredentials", () => {
|
||||
it("looks the item up by account and stops there when it exists", async () => {
|
||||
const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } }));
|
||||
describe("usage bars escalate as they fill", () => {
|
||||
let claudeHome: string;
|
||||
|
||||
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||
beforeEach(() => {
|
||||
claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-"));
|
||||
});
|
||||
|
||||
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith([
|
||||
"find-generic-password",
|
||||
"-a",
|
||||
"claude-code-user",
|
||||
"-w",
|
||||
"-s",
|
||||
SERVICE,
|
||||
afterEach(() => {
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function claudeAt(utilization: number) {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
process.env["CLAUDE_HOME"] = claudeHome;
|
||||
return fetchUsage(
|
||||
{},
|
||||
mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
() => jsonResponse({ seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" } }),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
[10, "ok"],
|
||||
[75, "warning"],
|
||||
[99, "danger"],
|
||||
])("a Claude window at %s%% is %s", async (utilization, tone) => {
|
||||
const usage = await claudeAt(utilization);
|
||||
expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude usage source scoped weekly limits", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
let claudeHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fableLimit(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
kind: "weekly_scoped",
|
||||
group: "weekly",
|
||||
percent: 0,
|
||||
severity: "normal",
|
||||
resets_at: "2026-06-04T00:00:00Z",
|
||||
is_active: false,
|
||||
scope: { model: { id: null, display_name: "Fable" }, surface: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function claudeProvider(body: unknown) {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
process.env["CLAUDE_HOME"] = claudeHome;
|
||||
const fetchApi = mockFetch(
|
||||
new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]),
|
||||
);
|
||||
return { provider: { fetchUsage: () => fetchUsage({}, fetchApi) } };
|
||||
}
|
||||
|
||||
it("renders a scoped weekly limit as its own window", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [fableLimit()],
|
||||
});
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows).toContainEqual(
|
||||
expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a scoped window that is at zero and inactive", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [fableLimit({ percent: 0, is_active: false })],
|
||||
});
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows).toContainEqual(
|
||||
expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [
|
||||
{ kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null },
|
||||
{ kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null },
|
||||
fableLimit(),
|
||||
],
|
||||
});
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows.map((window) => window.id)).toEqual([
|
||||
"five_hour",
|
||||
"weekly",
|
||||
"weekly_model_fable",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the account-less lookup when no item matches the account", async () => {
|
||||
const run = vi.fn(async (args: string[]) =>
|
||||
args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||
);
|
||||
|
||||
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||
|
||||
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } });
|
||||
expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]);
|
||||
});
|
||||
|
||||
it("falls through when the account item carries no access token", async () => {
|
||||
const run = vi.fn(async (args: string[]) =>
|
||||
args.includes("-a")
|
||||
? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } })
|
||||
: JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||
);
|
||||
|
||||
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||
|
||||
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } });
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is null when the Keychain holds no item at all", async () => {
|
||||
const run = vi.fn(async () => null);
|
||||
|
||||
expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("tries the next lookup when the item does not parse", async () => {
|
||||
const run = vi.fn(async (args: string[]) =>
|
||||
args.includes("-a")
|
||||
? "not-json"
|
||||
: JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||
);
|
||||
|
||||
expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({
|
||||
claudeAiOauth: { accessToken: "at_legacy" },
|
||||
it("labels a surface-scoped limit from its surface name", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [
|
||||
fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude usage input forms", () => {
|
||||
it("reads only the selected configDir file", async () => {
|
||||
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { ClaudeQuotaProvider } = await import("./usage.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }),
|
||||
);
|
||||
const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }));
|
||||
const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => {
|
||||
expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-file" });
|
||||
return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 });
|
||||
});
|
||||
const report = await new ClaudeQuotaProvider({
|
||||
logger: console,
|
||||
configDir: dir,
|
||||
platform: "darwin",
|
||||
claudeKeychainReader: keychain,
|
||||
fetch: fetchApi as never,
|
||||
}).fetchUsage();
|
||||
expect(report.status).toBe("available");
|
||||
expect(keychain).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows).toContainEqual(
|
||||
expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an accessToken without reading a file or keychain", async () => {
|
||||
const { ClaudeQuotaProvider } = await import("./usage.js");
|
||||
const keychain = vi.fn(async () => null);
|
||||
const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => {
|
||||
expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-direct" });
|
||||
return new Response(JSON.stringify({ seven_day: { utilization: 25 } }), { status: 200 });
|
||||
it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })],
|
||||
});
|
||||
const report = await new ClaudeQuotaProvider({
|
||||
logger: console,
|
||||
accessToken: "fixture-direct",
|
||||
configDir: "/nonexistent",
|
||||
platform: "darwin",
|
||||
claudeKeychainReader: keychain,
|
||||
fetch: fetchApi as never,
|
||||
}).fetchUsage();
|
||||
expect(report.status).toBe("available");
|
||||
expect(keychain).not.toHaveBeenCalled();
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]);
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("default discovery reads the credential file before keychain", async () => {
|
||||
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { ClaudeQuotaProvider } = await import("./usage.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }),
|
||||
);
|
||||
const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }));
|
||||
const fetchApi = vi.fn(async (_url: unknown, init: RequestInit) => {
|
||||
expect(init.headers).toMatchObject({ Authorization: "Bearer fixture-default" });
|
||||
return new Response(JSON.stringify({ five_hour: { utilization: 7 } }), { status: 200 });
|
||||
});
|
||||
const report = await new ClaudeQuotaProvider({
|
||||
logger: console,
|
||||
claudeHome: dir,
|
||||
platform: "darwin",
|
||||
claudeKeychainReader: keychain,
|
||||
fetch: fetchApi as never,
|
||||
}).fetchUsage();
|
||||
expect(report.status).toBe("available");
|
||||
expect(keychain).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
// Regression: an additive section must never take down data that already parsed.
|
||||
it("keeps the top-level windows when a limits entry is malformed", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [{ percent: "not-a-kind" }, fableLimit()],
|
||||
});
|
||||
|
||||
describe("Claude non-default config directory", () => {
|
||||
it("stays unavailable when its credential file is missing without probing keychain", async () => {
|
||||
const { mkdtempSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { ClaudeQuotaProvider } = await import("./usage.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-"));
|
||||
try {
|
||||
const keychain = vi.fn(async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }));
|
||||
const fetchApi = vi.fn();
|
||||
const report = await new ClaudeQuotaProvider({
|
||||
logger: console,
|
||||
configDir: dir,
|
||||
platform: "darwin",
|
||||
claudeKeychainReader: keychain,
|
||||
fetch: fetchApi as never,
|
||||
}).fetchUsage();
|
||||
expect(report.status).toBe("unavailable");
|
||||
expect(keychain).not.toHaveBeenCalled();
|
||||
expect(fetchApi).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.status).toBe("available");
|
||||
expect(usage.windows.map((window) => window.id)).toEqual([
|
||||
"five_hour",
|
||||
"weekly",
|
||||
"weekly_model_fable",
|
||||
]);
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when a successful response describes no windows at all", async () => {
|
||||
const { provider } = claudeProvider({ limits: [] });
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows).toEqual([]);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
"Claude usage response parsed but produced no windows",
|
||||
);
|
||||
});
|
||||
});
|
||||
/**
|
||||
* The reconciliation matrix.
|
||||
*
|
||||
* Four review rounds on PR #2303 each found a different hole in how the two
|
||||
* representations of one scoped limit get combined, because each fix was tested against
|
||||
* the case that was reported rather than the space of cases. This walks the space:
|
||||
* every combination of which representation carries the limit, whether the `limits[]`
|
||||
* entry supplies values, and whether the two descriptions denote the same limit at all.
|
||||
*/
|
||||
|
||||
describe("Claude usage source scoped limit reconciliation", () => {
|
||||
let claudeHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const RESETS = "2026-06-04T00:00:00Z";
|
||||
|
||||
function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) {
|
||||
return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope };
|
||||
}
|
||||
|
||||
const model = (name: string | null, id: string | null = null) => ({
|
||||
model: { id, display_name: name },
|
||||
surface: null,
|
||||
});
|
||||
const surface = (name: string | null, id: string | null = null) => ({
|
||||
model: null,
|
||||
surface: { id, display_name: name },
|
||||
});
|
||||
|
||||
async function windowsFor(body: Record<string, unknown>) {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
process.env["CLAUDE_HOME"] = claudeHome;
|
||||
const usage = await fetchUsage(
|
||||
{},
|
||||
mockFetch(new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]])),
|
||||
);
|
||||
return usage.windows;
|
||||
}
|
||||
|
||||
it("which representation carries the limit: legacy only", async () => {
|
||||
const windows = await windowsFor({
|
||||
seven_day_omelette: { utilization: 12, resets_at: RESETS },
|
||||
});
|
||||
expect(windows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "weekly_model_omelette",
|
||||
label: "Weekly · Omelette",
|
||||
usedPct: 12,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("which representation carries the limit: limits[] only", async () => {
|
||||
const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] });
|
||||
expect(windows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "weekly_model_fable",
|
||||
label: "Weekly · Fable",
|
||||
usedPct: 2,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => {
|
||||
const windows = await windowsFor({
|
||||
seven_day_omelette: { utilization: 12, resets_at: RESETS },
|
||||
limits: [scoped(model("Omelette"), 30)],
|
||||
});
|
||||
expect(windows).toEqual([
|
||||
expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("which representation carries the limit: both, different limits — two bars", async () => {
|
||||
const windows = await windowsFor({
|
||||
seven_day_opus: { utilization: 8, resets_at: RESETS },
|
||||
limits: [scoped(model("Fable"), 2)],
|
||||
});
|
||||
expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]);
|
||||
});
|
||||
|
||||
it("which representation carries the limit: neither", async () => {
|
||||
const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } });
|
||||
expect(windows.map((w) => w.id)).toEqual(["weekly"]);
|
||||
});
|
||||
|
||||
const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } };
|
||||
|
||||
it("value fallback when the scoped entry is sparse: scoped values win when present", async () => {
|
||||
const windows = await windowsFor({
|
||||
...legacy,
|
||||
limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")],
|
||||
});
|
||||
expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" });
|
||||
});
|
||||
|
||||
it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => {
|
||||
const windows = await windowsFor({
|
||||
...legacy,
|
||||
limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")],
|
||||
});
|
||||
expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" });
|
||||
});
|
||||
|
||||
it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => {
|
||||
const windows = await windowsFor({
|
||||
...legacy,
|
||||
limits: [scoped(model("Omelette"), 30, null)],
|
||||
});
|
||||
expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS });
|
||||
});
|
||||
|
||||
it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => {
|
||||
const windows = await windowsFor({
|
||||
...legacy,
|
||||
limits: [scoped(model("Omelette"), null, null)],
|
||||
});
|
||||
expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS });
|
||||
});
|
||||
|
||||
it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => {
|
||||
const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] });
|
||||
expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null });
|
||||
});
|
||||
|
||||
it("identity: a surface never matches a legacy model window of the same name", async () => {
|
||||
const windows = await windowsFor({
|
||||
seven_day_omelette: { utilization: 12, resets_at: RESETS },
|
||||
limits: [scoped(surface("Omelette"), 30)],
|
||||
});
|
||||
expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]);
|
||||
expect(windows[0]).toMatchObject({ usedPct: 12 });
|
||||
expect(windows[1]).toMatchObject({ usedPct: 30 });
|
||||
});
|
||||
|
||||
it("identity: a model and a surface of the same name stay apart", async () => {
|
||||
const windows = await windowsFor({
|
||||
limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)],
|
||||
});
|
||||
expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]);
|
||||
});
|
||||
|
||||
it("identity: ids decide when both sides have one", async () => {
|
||||
const windows = await windowsFor({
|
||||
limits: [
|
||||
scoped(model("Fable-Pro", "fable-pro"), 4),
|
||||
scoped(model("Fable_Pro", "fable_pro"), 9),
|
||||
],
|
||||
});
|
||||
expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]);
|
||||
});
|
||||
|
||||
it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => {
|
||||
const windows = await windowsFor({
|
||||
limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)],
|
||||
});
|
||||
expect(windows).toEqual([
|
||||
expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("identity: a renamed scope keeps its id when the API supplies one", async () => {
|
||||
const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] });
|
||||
const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] });
|
||||
expect(before[0]?.id).toBe("weekly_model_fable");
|
||||
expect(after[0]?.id).toBe("weekly_model_fable");
|
||||
expect(after[0]?.label).toBe("Weekly · Fable 5");
|
||||
});
|
||||
|
||||
it("identity: a limit keeps one id whichever representation carries it", async () => {
|
||||
const viaLegacy = await windowsFor({
|
||||
seven_day_omelette: { utilization: 12, resets_at: RESETS },
|
||||
});
|
||||
const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] });
|
||||
expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id);
|
||||
});
|
||||
|
||||
it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => {
|
||||
const windows = await windowsFor({
|
||||
five_hour: { utilization: 6, resets_at: RESETS },
|
||||
seven_day: { utilization: 23, resets_at: RESETS },
|
||||
seven_day_opus: { utilization: 8, resets_at: RESETS },
|
||||
limits: [
|
||||
{ kind: "session", percent: 6, resets_at: RESETS, scope: null },
|
||||
{ kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null },
|
||||
scoped(model("Fable"), 2),
|
||||
],
|
||||
});
|
||||
expect(windows.map((w) => w.id)).toEqual([
|
||||
"five_hour",
|
||||
"weekly",
|
||||
"weekly_model_opus",
|
||||
"weekly_model_fable",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
@@ -6,17 +7,16 @@ import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
fetchProviderApi,
|
||||
toneFromUsedPct,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
type UsageReport,
|
||||
type UsageWindow,
|
||||
type UsageDetail,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiNumberSchema = z.coerce.number().finite();
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
|
||||
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
||||
@@ -78,16 +78,6 @@ interface ClaudeCredentialRecord {
|
||||
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
|
||||
}
|
||||
|
||||
interface ClaudeQuotaProviderOptions {
|
||||
logger: Console;
|
||||
claudeHome?: string;
|
||||
configDir?: string;
|
||||
accessToken?: string;
|
||||
claudeKeychainReader?: () => Promise<unknown | null>;
|
||||
platform?: typeof process.platform;
|
||||
fetch?: UsageApiFetch;
|
||||
}
|
||||
|
||||
function buildClaudePlan(
|
||||
subscriptionType: string | undefined,
|
||||
rateLimitTier: string | undefined,
|
||||
@@ -337,75 +327,15 @@ export async function readClaudeKeychainCredentials(
|
||||
return null;
|
||||
}
|
||||
|
||||
export class ClaudeQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly claudeHome: string;
|
||||
private readonly configDir: string | undefined;
|
||||
private readonly accessToken: string | undefined;
|
||||
private readonly readKeychainCredentials: () => Promise<unknown | null>;
|
||||
private readonly platform: typeof process.platform;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
|
||||
constructor(options: ClaudeQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.claudeHome =
|
||||
options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude");
|
||||
this.configDir = options.configDir;
|
||||
this.accessToken = options.accessToken;
|
||||
this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials;
|
||||
this.platform = options.platform ?? process.platform;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const credentials = await this.readCredentials();
|
||||
if (!credentials) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const { oauth } = credentials;
|
||||
const accountKey = createHash("sha256").update(oauth.accessToken).digest("hex");
|
||||
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
|
||||
const resp = await this.callClaudeApi(oauth.accessToken);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
|
||||
return unavailableUsage(accountKey);
|
||||
}
|
||||
|
||||
const scoped = reconcileScopedLimits(
|
||||
legacyScopedLimits(resp),
|
||||
this.scopedLimitsFromResponse(resp.limits),
|
||||
);
|
||||
const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)];
|
||||
if (windows[0]) windows[0].headline = true;
|
||||
|
||||
if (windows.length === 0) {
|
||||
// The response parsed but described nothing. That silence is how the previous
|
||||
// shape change went unnoticed, so make it greppable. `warn` and not `debug`
|
||||
// because file logging defaults to `info`.
|
||||
this.logger.warn("Claude usage response parsed but produced no windows");
|
||||
}
|
||||
|
||||
const details: UsageDetail[] = [];
|
||||
const extraUsageEnabled = resp.extra_usage?.is_enabled;
|
||||
if (extraUsageEnabled !== undefined) {
|
||||
details.push({
|
||||
id: "extra_usage",
|
||||
label: "Extra usage",
|
||||
value: extraUsageEnabled ? "Enabled" : "Disabled",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
account: { key: accountKey },
|
||||
status: "available",
|
||||
planLabel: plan ?? undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
}
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
const claudeHome = process.env["CLAUDE_HOME"] || join(homedir(), ".claude");
|
||||
const configDir = "configDir" in input ? input.configDir : undefined;
|
||||
const accessToken = "accessToken" in input ? input.accessToken : undefined;
|
||||
const readKeychainCredentials = readClaudeKeychainCredentials;
|
||||
const platform = process.platform;
|
||||
|
||||
/**
|
||||
* Scoped limits carried by `limits[]`.
|
||||
@@ -413,21 +343,21 @@ export class ClaudeQuotaProvider {
|
||||
* Entries are validated one at a time so a single malformed or newly-shaped entry
|
||||
* cannot fail the whole response and take the windows that already parsed with it.
|
||||
*/
|
||||
private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] {
|
||||
function scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] {
|
||||
if (!limits) return [];
|
||||
|
||||
const parsed: ScopedLimit[] = [];
|
||||
for (const entry of limits) {
|
||||
const result = ClaudeLimitSchema.safeParse(entry);
|
||||
if (!result.success) {
|
||||
this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry");
|
||||
console.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry");
|
||||
continue;
|
||||
}
|
||||
if (result.data.kind !== SCOPED_WEEKLY_KIND) continue;
|
||||
|
||||
const limit = scopedLimitFromEntry(result.data);
|
||||
if (!limit) {
|
||||
this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name");
|
||||
console.warn("Skipping scoped Claude usage limit with no resolvable scope name");
|
||||
continue;
|
||||
}
|
||||
parsed.push(limit);
|
||||
@@ -435,20 +365,18 @@ export class ClaudeQuotaProvider {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async readCredentials(): Promise<ClaudeCredentialRecord | null> {
|
||||
if (this.accessToken) return { oauth: { accessToken: this.accessToken } };
|
||||
if (this.configDir) return this.readCredentialFile(join(this.configDir, ".credentials.json"));
|
||||
const credPath = join(this.claudeHome, ".credentials.json");
|
||||
const fileCredentials = await this.readCredentialFile(credPath);
|
||||
return (
|
||||
fileCredentials ?? (this.platform === "darwin" ? await this.readKeychainCredential() : null)
|
||||
);
|
||||
async function readCredentials(): Promise<ClaudeCredentialRecord | null> {
|
||||
if (accessToken) return { oauth: { accessToken: accessToken } };
|
||||
if (configDir) return readCredentialFile(join(configDir, ".credentials.json"));
|
||||
const credPath = join(claudeHome, ".credentials.json");
|
||||
const fileCredentials = await readCredentialFile(credPath);
|
||||
return fileCredentials ?? (platform === "darwin" ? await readKeychainCredential() : null);
|
||||
}
|
||||
|
||||
private async readCredentialFile(path: string): Promise<ClaudeCredentialRecord | null> {
|
||||
async function readCredentialFile(path: string): Promise<ClaudeCredentialRecord | null> {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return this.toCredentialRecord(
|
||||
return toCredentialRecord(
|
||||
ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))),
|
||||
);
|
||||
} catch {
|
||||
@@ -456,20 +384,21 @@ export class ClaudeQuotaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async readKeychainCredential(): Promise<ClaudeCredentialRecord | null> {
|
||||
const parsed = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
|
||||
return parsed.success ? this.toCredentialRecord(parsed.data) : null;
|
||||
async function readKeychainCredential(): Promise<ClaudeCredentialRecord | null> {
|
||||
const parsed = ClaudeCredentialsSchema.safeParse(await readKeychainCredentials());
|
||||
return parsed.success ? toCredentialRecord(parsed.data) : null;
|
||||
}
|
||||
|
||||
private toCredentialRecord(
|
||||
function toCredentialRecord(
|
||||
credentials: z.infer<typeof ClaudeCredentialsSchema>,
|
||||
): ClaudeCredentialRecord | null {
|
||||
const oauth = credentials.claudeAiOauth;
|
||||
return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null;
|
||||
}
|
||||
|
||||
private async callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", {
|
||||
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
|
||||
const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
@@ -480,4 +409,52 @@ export class ClaudeQuotaProvider {
|
||||
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
|
||||
return ClaudeUsageResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const { oauth } = credentials;
|
||||
const accountKey = createHash("sha256").update(oauth.accessToken).digest("hex");
|
||||
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
|
||||
const resp = await callClaudeApi(oauth.accessToken);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
|
||||
return unavailableUsage(accountKey);
|
||||
}
|
||||
|
||||
const scoped = reconcileScopedLimits(
|
||||
legacyScopedLimits(resp),
|
||||
scopedLimitsFromResponse(resp.limits),
|
||||
);
|
||||
const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)];
|
||||
if (windows[0]) windows[0].headline = true;
|
||||
|
||||
if (windows.length === 0) {
|
||||
// The response parsed but described nothing. That silence is how the previous
|
||||
// shape change went unnoticed, so make it greppable. `warn` and not `debug`
|
||||
// because file logging defaults to `info`.
|
||||
console.warn("Claude usage response parsed but produced no windows");
|
||||
}
|
||||
|
||||
const details: UsageDetail[] = [];
|
||||
const extraUsageEnabled = resp.extra_usage?.is_enabled;
|
||||
if (extraUsageEnabled !== undefined) {
|
||||
details.push({
|
||||
id: "extra_usage",
|
||||
label: "Extra usage",
|
||||
value: extraUsageEnabled ? "Enabled" : "Disabled",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
account: { key: accountKey },
|
||||
status: "available",
|
||||
planLabel: plan ?? undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { CopilotQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (_input) => new CopilotQuotaProvider({ logger: console }).fetchUsage(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "copilot-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "copilot-usage-source" }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("copilot usage source", () => {
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = 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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "copilot",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,24 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiOptionalStringSchema,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
type UsageReport,
|
||||
type UsageDetail,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const CopilotUsageResponseSchema = z.object({
|
||||
copilot_plan: ApiOptionalStringSchema,
|
||||
quota_reset_date: ApiOptionalStringSchema,
|
||||
});
|
||||
|
||||
interface CopilotQuotaProviderOptions {
|
||||
logger: Console;
|
||||
fetch?: UsageApiFetch;
|
||||
}
|
||||
|
||||
async function readGithubCliToken(): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
@@ -41,56 +39,47 @@ async function readGithubCliToken(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export class CopilotQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
|
||||
constructor(options: CopilotQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
const token =
|
||||
process.env["COPILOT_TOKEN"] ||
|
||||
process.env["GITHUB_TOKEN"] ||
|
||||
process.env["GITHUB_PAT"] ||
|
||||
(await readGithubCliToken());
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
const res = await fetchApi("https://api.github.com/copilot_internal/user", {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
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) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const token =
|
||||
process.env["COPILOT_TOKEN"] ||
|
||||
process.env["GITHUB_TOKEN"] ||
|
||||
process.env["GITHUB_PAT"] ||
|
||||
(await readGithubCliToken());
|
||||
const resp = CopilotUsageResponseSchema.parse(await res.json());
|
||||
const details: UsageDetail[] = resp.quota_reset_date
|
||||
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
|
||||
: [];
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const resp = CopilotUsageResponseSchema.parse(await res.json());
|
||||
const details: UsageDetail[] = resp.quota_reset_date
|
||||
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: resp.copilot_plan || undefined,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
}
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: resp.copilot_plan || undefined,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { CursorQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (_input) => new CursorQuotaProvider({ logger: console }).fetchUsage(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "cursor-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "cursor-usage-source" }
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
|
||||
const testRequire = createRequire(import.meta.url);
|
||||
interface TestSqliteDb {
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): { run(...params: unknown[]): void };
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the
|
||||
// real layouts: a plain modern token or the legacy JSON object.
|
||||
function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void {
|
||||
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const { DatabaseSync } = testRequire("node:sqlite") as {
|
||||
DatabaseSync: new (path: string) => TestSqliteDb;
|
||||
};
|
||||
const db = new DatabaseSync(join(dir, "state.vscdb"));
|
||||
db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
|
||||
const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
|
||||
for (const [key, value] of Object.entries(rows)) {
|
||||
insert.run(key, value);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
function writeCursorAuthJson(homeDir: string, accessToken: string): void {
|
||||
const dir = join(homeDir, ".config", "cursor");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken }));
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("cursor usage source", () => {
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = 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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "cursor",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
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("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => {
|
||||
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" });
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
planUsage: {
|
||||
totalSpend: "1500",
|
||||
includedSpend: "1000",
|
||||
bonusSpend: "500",
|
||||
remaining: "2500",
|
||||
limit: "4000",
|
||||
},
|
||||
billingCycleStart: "2026-01-14T12:42:14.000Z",
|
||||
billingCycleEnd: "2026-02-14T12:42:14.000Z",
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
|
||||
|
||||
expect(authorization).toBe("Bearer cursor_state_jwt");
|
||||
expect(cursor).toMatchObject({
|
||||
status: "available",
|
||||
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => {
|
||||
writeCursorStateDb(homeDir, {
|
||||
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"),
|
||||
});
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
|
||||
billingCycleStart: null,
|
||||
billingCycleEnd: null,
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
|
||||
|
||||
expect(authorization).toBe("Bearer cursor_legacy_jwt");
|
||||
expect(cursor.status).toBe("available");
|
||||
});
|
||||
|
||||
it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => {
|
||||
writeCursorAuthJson(homeDir, "cursor_cli_jwt");
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
planUsage: {
|
||||
totalSpend: "1500",
|
||||
includedSpend: "1000",
|
||||
bonusSpend: "500",
|
||||
remaining: "2500",
|
||||
limit: "4000",
|
||||
},
|
||||
billingCycleStart: "2026-01-14T12:42:14.000Z",
|
||||
billingCycleEnd: "2026-02-14T12:42:14.000Z",
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
|
||||
|
||||
expect(authorization).toBe("Bearer cursor_cli_jwt");
|
||||
expect(cursor).toMatchObject({
|
||||
status: "available",
|
||||
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => {
|
||||
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" });
|
||||
writeCursorAuthJson(homeDir, "cursor_cli_jwt");
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
|
||||
billingCycleStart: null,
|
||||
billingCycleEnd: null,
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
|
||||
|
||||
expect(authorization).toBe("Bearer cursor_desktop_jwt");
|
||||
expect(cursor.status).toBe("available");
|
||||
});
|
||||
|
||||
it("stays unavailable when state.vscdb is unreadable", async () => {
|
||||
const path = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
||||
mkdirSync(path, { recursive: true });
|
||||
writeFileSync(join(path, "state.vscdb"), "not a database");
|
||||
const cursor = findProvider(await service().listUsage(), "cursor");
|
||||
expect(cursor.status).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,27 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiNullableNumberSchema,
|
||||
toneFromUsedPct,
|
||||
usedPctOf,
|
||||
fetchProviderApi,
|
||||
toIsoStringOrNull,
|
||||
unavailableUsage,
|
||||
type UsageReport,
|
||||
type UsageBalance,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiNumberSchema = z.coerce.number().finite();
|
||||
const ApiNullableNumberSchema = z.preprocess(
|
||||
(value) => (value == null ? null : value),
|
||||
ApiNumberSchema.nullable(),
|
||||
);
|
||||
function toIsoStringOrNull(timestampMs: number): string | null {
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep
|
||||
// the access token as a plain JWT string under `cursorAuth/accessToken`; older builds
|
||||
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
|
||||
@@ -62,12 +69,6 @@ const CursorAuthStatusSchema = z.object({
|
||||
|
||||
type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
|
||||
|
||||
interface CursorQuotaProviderOptions {
|
||||
logger: Console;
|
||||
fetch?: UsageApiFetch;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
function parseCursorBillingCycleTimestamp(
|
||||
value: CursorUsageResponse["billingCycleStart"],
|
||||
): string | null {
|
||||
@@ -113,7 +114,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Promise<string | null> {
|
||||
async function readCursorTokenFromSqlite(homeDir: string): Promise<string | null> {
|
||||
const dbPaths: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
@@ -137,8 +138,7 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Prom
|
||||
let sqlite: NodeSqliteModule;
|
||||
try {
|
||||
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
|
||||
} catch (err) {
|
||||
logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb");
|
||||
} catch {
|
||||
return null; // runtime without node:sqlite
|
||||
}
|
||||
|
||||
@@ -149,10 +149,8 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Prom
|
||||
db = new sqlite.DatabaseSync(path, { readOnly: true });
|
||||
const token = cursorTokenFromDb(db);
|
||||
if (token) return token;
|
||||
} catch (err) {
|
||||
// Locked/permission/corrupt/schema failures all land here; log so an
|
||||
// unavailable Cursor card is diagnosable, then try the next candidate.
|
||||
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb");
|
||||
} catch {
|
||||
// Locked, permission, corrupt, or schema failures try the next candidate.
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
@@ -160,86 +158,75 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Prom
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readCursorTokenFromAuthJson(
|
||||
homeDir: string,
|
||||
logger: Console,
|
||||
): Promise<string | null> {
|
||||
async function readCursorTokenFromAuthJson(homeDir: string): Promise<string | null> {
|
||||
const path = join(homeDir, ".config", "cursor", "auth.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
return parsed.accessToken?.trim() || null;
|
||||
} catch (err) {
|
||||
logger.debug({ err, path }, "Failed to read Cursor token from auth.json");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class CursorQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
private readonly homeDir: string;
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
const homeDir = homedir();
|
||||
|
||||
constructor(options: CursorQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
this.homeDir = options.homeDir ?? homedir();
|
||||
}
|
||||
const token =
|
||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||
process.env["CURSOR_TOKEN"] ||
|
||||
(await readCursorTokenFromSqlite(homeDir)) ||
|
||||
(await readCursorTokenFromAuthJson(homeDir));
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const token =
|
||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||
process.env["CURSOR_TOKEN"] ||
|
||||
(await readCursorTokenFromSqlite(this.homeDir, this.logger)) ||
|
||||
(await readCursorTokenFromAuthJson(this.homeDir, this.logger));
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
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({}),
|
||||
const res = await fetchApi(
|
||||
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
{
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
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();
|
||||
}
|
||||
|
||||
const resp = CursorUsageResponseSchema.parse(await res.json());
|
||||
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
||||
const balances: UsageBalance[] = [];
|
||||
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: toneFromUsedPct(usedPctOf(totalSpend, limit)),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: undefined,
|
||||
windows: [],
|
||||
balances,
|
||||
details: [],
|
||||
};
|
||||
if (!res.ok) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const resp = CursorUsageResponseSchema.parse(await res.json());
|
||||
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
||||
const balances: UsageBalance[] = [];
|
||||
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: toneFromUsedPct(usedPctOf(totalSpend, limit)),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: undefined,
|
||||
windows: [],
|
||||
balances,
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { GrokQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (_input) => new GrokQuotaProvider({ logger: console }).fetchUsage(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "grok-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "grok-usage-source" }
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { mkdirSync, writeFileSync, 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 { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
|
||||
mkdirSync(join(home, ".grok"), { recursive: true });
|
||||
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("grok usage source", () => {
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = 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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "grok",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
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?format=credits",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: { monthlyLimit: { val: 0 }, used: { val: 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 Grok usage from live billing shape (config.used.val)", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: {
|
||||
monthlyLimit: { val: 150000 },
|
||||
used: { val: 37886 },
|
||||
billingPeriodStart: "2026-07-01T00:00:00+00:00",
|
||||
billingPeriodEnd: "2026-08-01T00:00:00+00:00",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 37886,
|
||||
remaining: 112114,
|
||||
limit: 150000,
|
||||
unit: "credits",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => {
|
||||
writeGrokAuth(homeDir, {
|
||||
"https://auth.x.ai::test-user-id": {
|
||||
key: "nested_jwt_token",
|
||||
refresh_token: "rt_nested",
|
||||
expires_at: "2026-08-01T00:00:00Z",
|
||||
user_id: "test-user-id",
|
||||
email: "user@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
config: {
|
||||
monthlyLimit: { val: 100 },
|
||||
used: { val: 25 },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(authorization).toBe("Bearer nested_jwt_token");
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 25,
|
||||
remaining: 75,
|
||||
limit: 100,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: { monthlyLimit: { val: 50 } },
|
||||
usage: { creditUsage: 10 },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 10,
|
||||
remaining: 40,
|
||||
limit: 50,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Grok unified-billing usage as a weekly window", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: {
|
||||
currentPeriod: {
|
||||
type: "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
start: "2026-08-24T09:41:40.001370+00:00",
|
||||
end: "2026-08-31T09:41:40.001370+00:00",
|
||||
},
|
||||
creditUsagePercent: 76.0,
|
||||
isUnifiedBillingUser: true,
|
||||
billingPeriodStart: "2026-08-24T09:41:40.001370+00:00",
|
||||
billingPeriodEnd: "2026-08-31T09:41:40.001370+00:00",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
windows: [
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
usedPct: 76,
|
||||
remainingPct: 24,
|
||||
resetsAt: "2026-08-31T09:41:40.001370+00:00",
|
||||
tone: "warning",
|
||||
},
|
||||
],
|
||||
balances: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
ApiOptionalStringSchema,
|
||||
toneFromUsedPct,
|
||||
usedPctOf,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
type UsageReport,
|
||||
type UsageWindow,
|
||||
type UsageBalance,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiNumberSchema = z.coerce.number().finite();
|
||||
const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const GrokUsageResponseSchema = z.object({
|
||||
config: z
|
||||
.object({
|
||||
@@ -45,13 +48,6 @@ const GrokUsageResponseSchema = z.object({
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
interface GrokQuotaProviderOptions {
|
||||
logger: Console;
|
||||
fetch?: UsageApiFetch;
|
||||
/** Override home directory (tests). Production uses os.homedir(). */
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
/** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */
|
||||
export function extractGrokTokenFromAuth(auth: unknown): string | null {
|
||||
if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null;
|
||||
@@ -108,60 +104,16 @@ function grokUsageWindow(response: z.infer<typeof GrokUsageResponseSchema>): Usa
|
||||
});
|
||||
}
|
||||
|
||||
export class GrokQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
private readonly homeDir: string | undefined;
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
const homeDir = homedir();
|
||||
|
||||
constructor(options: GrokQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
this.homeDir = options.homeDir;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const token =
|
||||
process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken());
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
// The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts
|
||||
// get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage.
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||
const balance = grokMonthlyCreditBalance(resp);
|
||||
const window = grokUsageWindow(resp);
|
||||
if (window) window.headline = true;
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: undefined,
|
||||
windows: window ? [window] : [],
|
||||
balances: balance ? [balance] : [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async readGrokToken(): Promise<string | null> {
|
||||
async function readGrokToken(): Promise<string | null> {
|
||||
// homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE).
|
||||
const path = join(this.homeDir ?? homedir(), ".grok", "auth.json");
|
||||
const path = join(homeDir ?? homedir(), ".grok", "auth.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
@@ -169,4 +121,37 @@ export class GrokQuotaProvider {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const token = process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await readGrokToken());
|
||||
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
// The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts
|
||||
// get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage.
|
||||
const res = await fetchApi("https://cli-chat-proxy.grok.com/v1/billing?format=credits", {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||
const balance = grokMonthlyCreditBalance(resp);
|
||||
const window = grokUsageWindow(resp);
|
||||
if (window) window.headline = true;
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: undefined,
|
||||
windows: window ? [window] : [],
|
||||
balances: balance ? [balance] : [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { KimiQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (_input) => new KimiQuotaProvider({ logger: console }).fetchUsage(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "kimi-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "kimi-usage-source" }
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync, 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 { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
function kimiCredentialPath(dir: string): string {
|
||||
return join(dir, "credentials", "kimi-code.json");
|
||||
}
|
||||
|
||||
function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void {
|
||||
mkdirSync(join(dir, "credentials"), { recursive: true });
|
||||
writeFileSync(
|
||||
kimiCredentialPath(dir),
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: "rt_kimi",
|
||||
expires_at: 1_798_812_800,
|
||||
expires_in: 900,
|
||||
scope: "kimi-code",
|
||||
token_type: "Bearer",
|
||||
...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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("kimi usage source", () => {
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = 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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "kimi",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
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",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Kimi usage from the CLI credential home", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token");
|
||||
let requestedUrl: string | null = null;
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
requestedUrl = url.toString();
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
usage: {
|
||||
limit: "200",
|
||||
remaining: "150",
|
||||
resetTime: "2026-06-23T05:12:17Z",
|
||||
},
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi");
|
||||
|
||||
expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages");
|
||||
expect(authorization).toBe("Bearer kimi_cli_token");
|
||||
expect(kimi).toMatchObject({
|
||||
status: "available",
|
||||
windows: [
|
||||
expect.objectContaining({
|
||||
id: "coding_usage",
|
||||
usedPct: 25,
|
||||
remainingPct: 75,
|
||||
resetsAt: "2026-06-23T05:12:17Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads Kimi credentials whose optional fields are null", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", {
|
||||
expires_at: null,
|
||||
expires_in: null,
|
||||
scope: null,
|
||||
token_type: null,
|
||||
});
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.kimi.com/coding/v1/usages",
|
||||
() => jsonResponse({ usage: { limit: "100", remaining: "60" } }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi");
|
||||
|
||||
expect(kimi.status).toBe("available");
|
||||
});
|
||||
|
||||
it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired");
|
||||
const credPath = kimiCredentialPath(join(homeDir, ".kimi-code"));
|
||||
const before = readFileSync(credPath, "utf8");
|
||||
let usageCalls = 0;
|
||||
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
|
||||
const endpoint = url.toString();
|
||||
if (endpoint === "https://api.kimi.com/coding/v1/usages") {
|
||||
usageCalls += 1;
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
// The read-only fetcher must never hit the OAuth token endpoint.
|
||||
throw new Error(`Unmocked: ${endpoint}`);
|
||||
}) as never;
|
||||
|
||||
const result = await service({ kimiHomeDir: homeDir }).listUsage();
|
||||
|
||||
expect(findProvider(result, "kimi").status).toBe("unavailable");
|
||||
expect(usageCalls).toBe(1);
|
||||
// The credentials file must be left untouched for the Kimi CLI to own.
|
||||
expect(readFileSync(credPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("does not refresh Kimi tokens read from the environment", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
const usageFetch = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
fetchApi = usageFetch as never;
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "kimi").status).toBe("unavailable");
|
||||
expect(usageFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refresh Kimi tokens on a 403", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_forbidden");
|
||||
const usageFetch = vi.fn(async () => new Response(null, { status: 403 }));
|
||||
fetchApi = usageFetch as never;
|
||||
|
||||
const result = await service({ kimiHomeDir: homeDir }).listUsage();
|
||||
|
||||
expect(findProvider(result, "kimi").status).toBe("unavailable");
|
||||
expect(usageFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kimi usage source usage windows", () => {
|
||||
afterEach(() => {
|
||||
delete process.env["KIMI_TOKEN"];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("normalizes weekly and enforced rolling usage windows", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
const fetchApi = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
limited: true,
|
||||
usage: {
|
||||
limit: "100",
|
||||
used: "61",
|
||||
remaining: "39",
|
||||
resetTime: "2026-08-05T00:01:45Z",
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
window: {
|
||||
duration: 300,
|
||||
timeUnit: "TIME_UNIT_MINUTE",
|
||||
},
|
||||
detail: {
|
||||
limit: "100",
|
||||
used: "100",
|
||||
resetTime: "2026-07-31T17:01:45Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const usage = await fetchUsage({}, fetchApi);
|
||||
|
||||
expect(usage).toMatchObject({
|
||||
status: "available",
|
||||
windows: [
|
||||
{
|
||||
id: "coding_usage",
|
||||
label: "Weekly limit",
|
||||
usedPct: 61,
|
||||
remainingPct: 39,
|
||||
resetsAt: "2026-08-05T00:01:45Z",
|
||||
tone: "ok",
|
||||
},
|
||||
{
|
||||
id: "coding_limit_300_time_unit_minute",
|
||||
label: "5-hour limit",
|
||||
usedPct: 100,
|
||||
remainingPct: 0,
|
||||
resetsAt: "2026-07-31T17:01:45Z",
|
||||
tone: "danger",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps valid windows when another limits entry is malformed", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
const fetchApi = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
usage: {
|
||||
limit: "100",
|
||||
remaining: "75",
|
||||
resetTime: "2026-08-05T00:01:45Z",
|
||||
},
|
||||
limits: [
|
||||
{ window: { duration: "invalid" }, detail: {} },
|
||||
{
|
||||
window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
|
||||
detail: { limit: "100", remaining: "50" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const usage = await fetchUsage({}, fetchApi);
|
||||
|
||||
expect(usage.windows).toHaveLength(2);
|
||||
expect(usage.windows[1]).toMatchObject({
|
||||
label: "5-hour limit",
|
||||
usedPct: 50,
|
||||
remainingPct: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts direct limit fields, alternate reset keys, and provider labels", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
const fetchApi = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
usage: null,
|
||||
limits: [
|
||||
{
|
||||
name: "Burst quota",
|
||||
limit: "80",
|
||||
remaining: "20",
|
||||
reset_at: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const usage = await fetchUsage({}, fetchApi);
|
||||
|
||||
expect(usage.windows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "coding_limit_burst_quota",
|
||||
label: "Burst quota",
|
||||
usedPct: 75,
|
||||
remainingPct: 25,
|
||||
resetsAt: "2026-08-01T00:00:00Z",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps window ids unique when Kimi returns duplicate limit descriptors", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
const duplicate = {
|
||||
window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
|
||||
detail: { limit: "100", used: "10" },
|
||||
};
|
||||
const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] }));
|
||||
const usage = await fetchUsage({}, fetchApi);
|
||||
|
||||
expect(usage.windows.map((window) => window.id)).toEqual([
|
||||
"coding_limit_300_time_unit_minute",
|
||||
"coding_limit_300_time_unit_minute_2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
ApiOptionalStringSchema,
|
||||
fetchProviderApi,
|
||||
toneFromUsedPct,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
type UsageReport,
|
||||
type UsageWindow,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiNumberSchema = z.coerce.number().finite();
|
||||
const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
|
||||
const KimiUsageFieldsSchema = z.object({
|
||||
@@ -187,10 +190,7 @@ function uniqueWindowId(baseId: string, seenIds: Set<string>): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
function kimiUsageWindowsFromPayload(
|
||||
payload: unknown,
|
||||
logger: Pick<Console, "debug">,
|
||||
): UsageWindow[] {
|
||||
function kimiUsageWindowsFromPayload(payload: unknown): UsageWindow[] {
|
||||
const response = KimiUsageResponseSchema.parse(payload);
|
||||
const windows: UsageWindow[] = [];
|
||||
const seenWindowIds = new Set<string>();
|
||||
@@ -210,14 +210,12 @@ function kimiUsageWindowsFromPayload(
|
||||
for (const [index, rawLimit] of limits.entries()) {
|
||||
const parsedLimit = KimiUsageLimitSchema.safeParse(rawLimit);
|
||||
if (!parsedLimit.success) {
|
||||
logger.debug({ index }, "Ignoring malformed Kimi usage limit window");
|
||||
continue;
|
||||
}
|
||||
|
||||
const limit = parsedLimit.data;
|
||||
const { fields, metadata } = limitFields(limit);
|
||||
if (!hasUsageData(fields)) {
|
||||
logger.debug({ index }, "Ignoring malformed Kimi usage limit window");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -249,50 +247,16 @@ const KimiAuthSchema = z
|
||||
type KimiAuth = z.infer<typeof KimiAuthSchema>;
|
||||
type KimiCredentials = KimiAuth & { access_token: string };
|
||||
|
||||
interface KimiQuotaProviderOptions {
|
||||
logger: Console;
|
||||
fetch?: UsageApiFetch;
|
||||
homeDir?: string;
|
||||
}
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
const homeDir = homedir();
|
||||
|
||||
export class KimiQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
private readonly homeDir?: string;
|
||||
|
||||
constructor(options: KimiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
this.homeDir = options.homeDir;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const credentials = await this.readCredentials();
|
||||
if (!credentials) return unavailableUsage();
|
||||
|
||||
const res = await this.callUsageApi(credentials.access_token);
|
||||
|
||||
if (!res.ok) {
|
||||
// Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md.
|
||||
this.logger.debug({ status: res.status }, "Kimi usage fetch failed");
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const windows = kimiUsageWindowsFromPayload(await res.json(), this.logger);
|
||||
if (windows[0]) windows[0].headline = true;
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async callUsageApi(token: string): Promise<Response> {
|
||||
return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, {
|
||||
async function callUsageApi(token: string): Promise<Response> {
|
||||
return fetchApi(KIMI_USAGE_URL, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
@@ -300,14 +264,14 @@ export class KimiQuotaProvider {
|
||||
});
|
||||
}
|
||||
|
||||
private async readCredentials(): Promise<KimiCredentials | null> {
|
||||
async function readCredentials(): Promise<KimiCredentials | null> {
|
||||
const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"];
|
||||
if (environmentToken) {
|
||||
return { access_token: environmentToken };
|
||||
}
|
||||
|
||||
for (const path of this.credentialPaths()) {
|
||||
const credentials = await this.readCredentialFile(path);
|
||||
for (const path of credentialPaths()) {
|
||||
const credentials = await readCredentialFile(path);
|
||||
if (credentials?.access_token) {
|
||||
return { ...credentials, access_token: credentials.access_token };
|
||||
}
|
||||
@@ -315,19 +279,19 @@ export class KimiQuotaProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
private credentialPaths(): string[] {
|
||||
const homeDir = this.homeDir ?? homedir();
|
||||
function credentialPaths(): string[] {
|
||||
const home = homeDir;
|
||||
return [
|
||||
join(
|
||||
process.env["KIMI_CODE_HOME"] || join(homeDir, ".kimi-code"),
|
||||
process.env["KIMI_CODE_HOME"] || join(home, ".kimi-code"),
|
||||
"credentials",
|
||||
"kimi-code.json",
|
||||
),
|
||||
join(homeDir, ".kimi", "credentials", "kimi-code.json"),
|
||||
join(home, ".kimi", "credentials", "kimi-code.json"),
|
||||
];
|
||||
}
|
||||
|
||||
private async readCredentialFile(path: string): Promise<KimiAuth | null> {
|
||||
async function readCredentialFile(path: string): Promise<KimiAuth | null> {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
@@ -335,4 +299,27 @@ export class KimiQuotaProvider {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) return unavailableUsage();
|
||||
|
||||
const res = await callUsageApi(credentials.access_token);
|
||||
|
||||
if (!res.ok) {
|
||||
// Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md.
|
||||
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const windows = kimiUsageWindowsFromPayload(await res.json());
|
||||
if (windows[0]) windows[0].headline = true;
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { MiniMaxQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (_input) => new MiniMaxQuotaProvider({ logger: console }).fetchUsage(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "minimax-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "minimax-usage-source" }
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { mkdirSync, writeFileSync, 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 { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
function writeMiniMaxConfig(dir: string, payload: Record<string, unknown>): void {
|
||||
mkdirSync(join(dir, ".mmx"), { recursive: true });
|
||||
writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function writeMiniMaxCredentials(
|
||||
dir: string,
|
||||
accessToken: string,
|
||||
expiresAt?: string,
|
||||
resourceUrl?: string,
|
||||
): void {
|
||||
mkdirSync(join(dir, ".mmx"), { recursive: true });
|
||||
const payload: Record<string, unknown> = { access_token: accessToken };
|
||||
if (expiresAt !== undefined) payload["expires_at"] = expiresAt;
|
||||
if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl;
|
||||
writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload));
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("minimax usage source", () => {
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = 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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "minimax",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
it("fetches MiniMax usage from MINIMAX_API_KEY against the global endpoint", async () => {
|
||||
process.env["MINIMAX_API_KEY"] = "minimax_test_token";
|
||||
let requestedUrl: string | null = null;
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
requestedUrl = url.toString();
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
model_remains: [
|
||||
{
|
||||
model_name: "MiniMax-M2.7",
|
||||
end_time: Date.parse("2026-06-19T05:00:00.000Z"),
|
||||
weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"),
|
||||
current_interval_total_count: 1000,
|
||||
current_interval_usage_count: 250,
|
||||
current_interval_remaining_percent: 75,
|
||||
current_weekly_total_count: 5000,
|
||||
current_weekly_usage_count: 1200,
|
||||
current_weekly_remaining_percent: 76,
|
||||
},
|
||||
],
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains");
|
||||
expect(authorization).toBe("Bearer minimax_test_token");
|
||||
expect(miniMax).toMatchObject({
|
||||
status: "available",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "interval_MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7 · Interval",
|
||||
usedPct: 25,
|
||||
remainingPct: 75,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "weekly_MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7 · Weekly",
|
||||
usedPct: 24,
|
||||
remainingPct: 76,
|
||||
resetsAt: "2026-06-26T00:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unavailable MiniMax usage when no credentials are configured", async () => {
|
||||
fetchApi = vi.fn() as never;
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(miniMax.status).toBe("unavailable");
|
||||
expect(fetchApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads MiniMax OAuth credentials from the CLI credentials file", async () => {
|
||||
writeMiniMaxCredentials(
|
||||
homeDir,
|
||||
"minimax_oauth_token",
|
||||
"2030-01-01T00:00:00.000Z",
|
||||
"https://account.example.com",
|
||||
);
|
||||
let requestedUrl: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL) => {
|
||||
requestedUrl = url.toString();
|
||||
return jsonResponse({ model_remains: [] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await service().listUsage();
|
||||
|
||||
expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains");
|
||||
});
|
||||
|
||||
it("falls back to MiniMax api_key in the CLI config file", async () => {
|
||||
writeMiniMaxConfig(homeDir, {
|
||||
api_key: "minimax_config_key",
|
||||
region: "cn",
|
||||
});
|
||||
let requestedUrl: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL) => {
|
||||
requestedUrl = url.toString();
|
||||
return jsonResponse({ model_remains: [] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains");
|
||||
expect(miniMax.status).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("reports MiniMax accounts with no active token plan as unavailable", async () => {
|
||||
writeMiniMaxConfig(homeDir, { api_key: "minimax_config_key", region: "cn" });
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.minimaxi.com/v1/token_plan/remains",
|
||||
() =>
|
||||
jsonResponse({
|
||||
model_remains: null,
|
||||
base_resp: { status_code: 2062, status_msg: "no active token plan subscription" },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null });
|
||||
});
|
||||
|
||||
it("reports a null MiniMax token plan as unavailable even without base_resp", async () => {
|
||||
process.env["MINIMAX_API_KEY"] = "minimax_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.minimax.io/v1/token_plan/remains",
|
||||
() => jsonResponse({ model_remains: null }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(miniMax).toMatchObject({ status: "unavailable", windows: [], error: null });
|
||||
});
|
||||
|
||||
it("still reads MiniMax windows when base_resp reports success", async () => {
|
||||
process.env["MINIMAX_API_KEY"] = "minimax_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.minimax.io/v1/token_plan/remains",
|
||||
() =>
|
||||
jsonResponse({
|
||||
base_resp: { status_code: 0, status_msg: "success" },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: "MiniMax-M2.7",
|
||||
end_time: Date.parse("2026-06-19T05:00:00.000Z"),
|
||||
current_interval_total_count: 100,
|
||||
current_interval_usage_count: 25,
|
||||
current_interval_remaining_percent: 75,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(miniMax).toMatchObject({
|
||||
status: "available",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "interval_MiniMax-M2.7", usedPct: 25 }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("marks exhausted MiniMax interval windows with a danger tone", async () => {
|
||||
process.env["MINIMAX_API_KEY"] = "minimax_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.minimax.io/v1/token_plan/remains",
|
||||
() =>
|
||||
jsonResponse({
|
||||
model_remains: [
|
||||
{
|
||||
model_name: "MiniMax-M2.7",
|
||||
end_time: Date.parse("2026-06-19T05:00:00.000Z"),
|
||||
weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"),
|
||||
current_interval_total_count: 100,
|
||||
current_interval_usage_count: 100,
|
||||
current_interval_remaining_percent: 0,
|
||||
current_interval_status: 2,
|
||||
current_weekly_total_count: 100,
|
||||
current_weekly_usage_count: 10,
|
||||
current_weekly_remaining_percent: 90,
|
||||
current_weekly_status: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const miniMax = findProvider(await service().listUsage(), "minimax");
|
||||
|
||||
expect(miniMax).toMatchObject({
|
||||
status: "available",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "interval_MiniMax-M2.7",
|
||||
usedPct: 100,
|
||||
tone: "danger",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "weekly_MiniMax-M2.7",
|
||||
tone: "ok",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
ApiOptionalStringSchema,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
type UsageReport,
|
||||
type UsageWindow,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiNumberSchema = z.coerce.number().finite();
|
||||
const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io";
|
||||
const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com";
|
||||
const MINIMAX_CREDENTIALS_PATH = join(homedir(), ".mmx", "credentials.json");
|
||||
const MINIMAX_CONFIG_PATH = join(homedir(), ".mmx", "config.json");
|
||||
|
||||
const MiniMaxModelRemainSchema = z.object({
|
||||
model_name: ApiOptionalStringSchema,
|
||||
@@ -73,15 +74,6 @@ interface MiniMaxResolvedAuth {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface MiniMaxQuotaProviderOptions {
|
||||
logger: Console;
|
||||
fetch?: UsageApiFetch;
|
||||
configPath?: string;
|
||||
credentialsPath?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(input: { baseUrl?: string; region?: string }): string {
|
||||
const explicit = input.baseUrl;
|
||||
if (explicit && explicit.startsWith("http")) return explicit;
|
||||
@@ -152,91 +144,35 @@ function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): UsageWind
|
||||
});
|
||||
}
|
||||
|
||||
export class MiniMaxQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
private readonly configPath: string;
|
||||
private readonly credentialsPath: string;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly now: () => number;
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
const configPath = join(homedir(), ".mmx", "config.json");
|
||||
const credentialsPath = join(homedir(), ".mmx", "credentials.json");
|
||||
const env = process.env;
|
||||
const now = Date.now;
|
||||
|
||||
constructor(options: MiniMaxQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
this.configPath = options.configPath ?? MINIMAX_CONFIG_PATH;
|
||||
this.credentialsPath = options.credentialsPath ?? MINIMAX_CREDENTIALS_PATH;
|
||||
this.env = options.env ?? process.env;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const auth = await this.resolveAuth();
|
||||
if (!auth) return unavailableUsage();
|
||||
|
||||
const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "MiniMax usage fetch failed");
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const resp = MiniMaxQuotaResponseSchema.parse(await res.json());
|
||||
|
||||
const statusCode = resp.base_resp?.status_code;
|
||||
if (typeof statusCode === "number" && statusCode !== 0) {
|
||||
this.logger.debug(
|
||||
{ statusCode, statusMsg: resp.base_resp?.status_msg },
|
||||
"MiniMax usage unavailable",
|
||||
);
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const models = resp.model_remains ?? [];
|
||||
|
||||
const windows: UsageWindow[] = [];
|
||||
for (const model of models) {
|
||||
const name = model.model_name ?? "token-plan";
|
||||
const intervalWindow = toIntervalWindow(name, model);
|
||||
if (intervalWindow) windows.push(intervalWindow);
|
||||
const weeklyWindow = toWeeklyWindow(name, model);
|
||||
if (weeklyWindow) windows.push(weeklyWindow);
|
||||
}
|
||||
if (windows[0]) windows[0].headline = true;
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: windows.length > 0 ? "available" : "unavailable",
|
||||
planLabel: undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveAuth(): Promise<MiniMaxResolvedAuth | null> {
|
||||
const envToken = this.env["MINIMAX_API_KEY"];
|
||||
async function resolveAuth(): Promise<MiniMaxResolvedAuth | null> {
|
||||
const envToken = env["MINIMAX_API_KEY"];
|
||||
if (envToken) {
|
||||
const envBase = this.env["MINIMAX_BASE_URL"];
|
||||
const envBase = env["MINIMAX_BASE_URL"];
|
||||
return {
|
||||
token: envToken,
|
||||
baseUrl: resolveBaseUrl({ baseUrl: envBase }),
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.readCredentials();
|
||||
if (credentials?.access_token && !this.isExpired(credentials.expires_at)) {
|
||||
const credentials = await readCredentials();
|
||||
if (credentials?.access_token && !isExpired(credentials.expires_at)) {
|
||||
return {
|
||||
token: credentials.access_token,
|
||||
baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }),
|
||||
};
|
||||
}
|
||||
|
||||
const config = await this.readConfig();
|
||||
const config = await readConfig();
|
||||
if (config?.api_key) {
|
||||
return {
|
||||
token: config.api_key,
|
||||
@@ -247,7 +183,7 @@ export class MiniMaxQuotaProvider {
|
||||
};
|
||||
}
|
||||
|
||||
if (config?.oauth?.access_token && !this.isExpired(config.oauth.expires_at)) {
|
||||
if (config?.oauth?.access_token && !isExpired(config.oauth.expires_at)) {
|
||||
return {
|
||||
token: config.oauth.access_token,
|
||||
baseUrl: resolveBaseUrl({
|
||||
@@ -260,30 +196,73 @@ export class MiniMaxQuotaProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
private isExpired(expiresAt: string | null | undefined): boolean {
|
||||
function isExpired(expiresAt: string | null | undefined): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const parsed = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(parsed)) return false;
|
||||
return parsed <= this.now();
|
||||
return parsed <= now();
|
||||
}
|
||||
|
||||
private async readCredentials(): Promise<z.infer<typeof MiniMaxCredentialsSchema> | null> {
|
||||
if (!existsSync(this.credentialsPath)) return null;
|
||||
async function readCredentials(): Promise<z.infer<typeof MiniMaxCredentialsSchema> | null> {
|
||||
if (!existsSync(credentialsPath)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(this.credentialsPath, "utf8"));
|
||||
const raw = JSON.parse(await fs.readFile(credentialsPath, "utf8"));
|
||||
return MiniMaxCredentialsSchema.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readConfig(): Promise<z.infer<typeof MiniMaxConfigSchema> | null> {
|
||||
if (!existsSync(this.configPath)) return null;
|
||||
async function readConfig(): Promise<z.infer<typeof MiniMaxConfigSchema> | null> {
|
||||
if (!existsSync(configPath)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(this.configPath, "utf8"));
|
||||
const raw = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
return MiniMaxConfigSchema.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const auth = await resolveAuth();
|
||||
if (!auth) return unavailableUsage();
|
||||
|
||||
const res = await fetchApi(`${auth.baseUrl}/v1/token_plan/remains`, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const resp = MiniMaxQuotaResponseSchema.parse(await res.json());
|
||||
|
||||
const statusCode = resp.base_resp?.status_code;
|
||||
if (typeof statusCode === "number" && statusCode !== 0) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
const models = resp.model_remains ?? [];
|
||||
|
||||
const windows: UsageWindow[] = [];
|
||||
for (const model of models) {
|
||||
const name = model.model_name ?? "token-plan";
|
||||
const intervalWindow = toIntervalWindow(name, model);
|
||||
if (intervalWindow) windows.push(intervalWindow);
|
||||
const weeklyWindow = toWeeklyWindow(name, model);
|
||||
if (weeklyWindow) windows.push(weeklyWindow);
|
||||
}
|
||||
if (windows[0]) windows[0].headline = true;
|
||||
|
||||
return {
|
||||
account: { key: "default" },
|
||||
status: windows.length > 0 ? "available" : "unavailable",
|
||||
planLabel: undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { inputSchema } from "./shared/input.js";
|
||||
import { ZaiQuotaProvider } from "./server/usage.js";
|
||||
import { fetchUsage } from "./server/usage.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerUsageSource({
|
||||
@@ -9,7 +9,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: async () => [{}],
|
||||
fetch: async (_input) => new ZaiQuotaProvider({ logger: console }).fetchUsage(),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "zai-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "zai-usage-source" }
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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 { fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("zai usage source", () => {
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-home-"));
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
process.env["USERPROFILE"] = 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",
|
||||
"KIMI_CODE_HOME",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_BASE_URL",
|
||||
])
|
||||
delete process.env[key];
|
||||
fetchApi = mockFetch(new Map());
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key];
|
||||
for (const key in originalEnv) process.env[key] = originalEnv[key];
|
||||
});
|
||||
function service(
|
||||
_options: {
|
||||
platform?: typeof process.platform;
|
||||
keychain?: () => Promise<unknown | null>;
|
||||
cursorHomeDir?: string;
|
||||
kimiHomeDir?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
listUsage: async () => {
|
||||
const report = await fetchUsage({}, (url, init) => fetchApi(url, init));
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
providerId: "zai",
|
||||
...report,
|
||||
error: report.error ?? null,
|
||||
planLabel: report.planLabel ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function findProvider(
|
||||
result: { providers: Array<{ providerId: string } & UsageReport> },
|
||||
id: string,
|
||||
) {
|
||||
const report = result.providers.find((item) => item.providerId === id);
|
||||
if (!report) throw new Error(`Missing usage source ${id}`);
|
||||
return report;
|
||||
}
|
||||
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" }]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiOptionalStringSchema,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
type UsageReport,
|
||||
type UsageDetail,
|
||||
type UsageApiFetch,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
|
||||
const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const ZaiUsageResponseSchema = z.object({
|
||||
data: z
|
||||
.array(
|
||||
@@ -21,58 +24,44 @@ const ZaiUsageResponseSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface ZaiQuotaProviderOptions {
|
||||
logger: Console;
|
||||
fetch?: UsageApiFetch;
|
||||
}
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
void input;
|
||||
|
||||
export class ZaiQuotaProvider {
|
||||
private readonly logger: Console;
|
||||
private readonly fetchApi: UsageApiFetch;
|
||||
const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"];
|
||||
if (!token) return unavailableUsage();
|
||||
|
||||
constructor(options: ZaiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return unavailableUsage();
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<UsageReport> {
|
||||
const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"];
|
||||
if (!token) return unavailableUsage();
|
||||
const resp = ZaiUsageResponseSchema.parse(await res.json());
|
||||
const sub = resp.data?.[0];
|
||||
if (!sub) return unavailableUsage();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const resp = ZaiUsageResponseSchema.parse(await res.json());
|
||||
const sub = resp.data?.[0];
|
||||
if (!sub) return unavailableUsage();
|
||||
|
||||
const details: UsageDetail[] = [];
|
||||
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 {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: sub.productName || undefined,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
const details: UsageDetail[] = [];
|
||||
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 {
|
||||
account: { key: "default" },
|
||||
status: "available",
|
||||
planLabel: sub.productName || undefined,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({}).strict();
|
||||
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -52,7 +52,7 @@ export class UsageSourceRegistry {
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
// COMPAT(providerUsageList): added in v0.9.2, remove after 2027-03-26.
|
||||
// COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26.
|
||||
async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> {
|
||||
const reports = await this.listReports();
|
||||
return {
|
||||
|
||||
@@ -782,8 +782,8 @@ describe("relative typed-entry configuration", () => {
|
||||
"packages",
|
||||
"server",
|
||||
"src",
|
||||
"services",
|
||||
"quota-fetcher",
|
||||
"plugins",
|
||||
"usage-sources",
|
||||
"providers",
|
||||
"local.ts",
|
||||
);
|
||||
@@ -792,7 +792,7 @@ describe("relative typed-entry configuration", () => {
|
||||
|
||||
const results = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "packages/server/src/services/quota-fetcher/providers/local.ts",
|
||||
query: "packages/server/src/plugins/usage-sources/providers/local.ts",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
@@ -802,7 +802,7 @@ describe("relative typed-entry configuration", () => {
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
path: "packages/server/src/services/quota-fetcher/providers/local.ts",
|
||||
path: "packages/server/src/plugins/usage-sources/providers/local.ts",
|
||||
kind: "file",
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user