feat(usage): migrate usage fetchers to plugin sources

Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source.
This commit is contained in:
Mohamed Boudra
2026-09-26 20:52:37 +02:00
parent d0cb4e6be8
commit 0f2f6a3b89
59 changed files with 822 additions and 1213 deletions
+2
View File
@@ -31,6 +31,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
+6
View File
@@ -376,6 +376,12 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
+6 -15
View File
@@ -195,26 +195,17 @@ promise for completion: equal results, including equal discovery timestamps, emi
---
## Provider Usage Fetchers
## Usage sources
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin process, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
Create an internal source under `packages/server/src/plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Register internal sources in `packages/server/src/plugins/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
### Credentials are read only
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
---
+50 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "vitest";
import { describe, expect, it, test } from "vitest";
import {
balanceToneFromRemaining,
toneFromUsedPct,
@@ -36,3 +36,52 @@ test("shared windows retain percentage, reset time, and optional headline", () =
expect(usedPctOf(25, 100)).toBe(25);
expect(usedPctOf(25, 0)).toBeNull();
});
describe("toneFromUsedPct", () => {
// Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what
// the client applies when a window arrives without a tone.
it.each([
[0, "ok"],
[69.9, "ok"],
[70, "warning"],
[90, "warning"],
[90.1, "danger"],
[100, "danger"],
[150, "danger"],
])("%s%% used is %s", (usedPct, expected) => {
expect(toneFromUsedPct(usedPct)).toBe(expected);
});
it("is neutral when the percentage is unknown", () => {
expect(toneFromUsedPct(null)).toBe("default");
expect(toneFromUsedPct(undefined)).toBe("default");
});
});
describe("usedPctOf", () => {
it("computes a percentage of the limit", () => {
expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2);
});
it("is unknown when either side is missing", () => {
expect(usedPctOf(null, 100)).toBeNull();
expect(usedPctOf(50, null)).toBeNull();
});
// A zero limit would divide to Infinity and render as a full red bar.
it("is unknown when the limit is zero or negative", () => {
expect(usedPctOf(50, 0)).toBeNull();
expect(usedPctOf(50, -1)).toBeNull();
});
});
describe("balanceToneFromRemaining", () => {
// Kept for balances with no limit, where no percentage can be computed. It only
// escalates at exhaustion, which is why anything with a limit should use
// toneFromUsedPct instead.
it("stays ok until nothing is left", () => {
expect(balanceToneFromRemaining(0.01)).toBe("ok");
expect(balanceToneFromRemaining(0)).toBe("danger");
expect(balanceToneFromRemaining(null)).toBe("default");
});
});
+39 -1
View File
@@ -1,4 +1,4 @@
import type { ZodType } from "zod";
import { z, type ZodType } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
export interface UsageWindow {
@@ -107,3 +107,41 @@ export function usedPctOf(
if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null;
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 },
status: "unavailable",
windows: [],
balances: [],
details: [],
};
}
export function toIsoStringOrNull(timestampMs: number): string | null {
const date = new Date(timestampMs);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,22 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { ClaudeQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "claude",
label: "Claude",
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();
},
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "claude-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -0,0 +1,194 @@
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" },
});
});
});
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 });
}
});
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 });
});
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();
});
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 });
}
});
});
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 });
}
});
});
@@ -1,23 +1,21 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, promises as fs } from "node:fs";
import { homedir, userInfo } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { z } from "zod";
import type {
ProviderUsage,
ProviderUsageDetail,
ProviderUsageWindow,
} from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import {
ApiNumberSchema,
fetchProviderApi,
toneFromUsedPct,
unavailableUsage,
windowFromUsedPct,
} from "../usage.js";
type UsageReport,
type UsageWindow,
type UsageDetail,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
const execFileAsync = promisify(execFile);
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
@@ -81,11 +79,13 @@ interface ClaudeCredentialRecord {
}
interface ClaudeQuotaProviderOptions {
logger: Logger;
logger: Console;
claudeHome?: string;
configDir?: string;
accessToken?: string;
claudeKeychainReader?: () => Promise<unknown | null>;
platform?: typeof process.platform;
fetch?: ProviderApiFetch;
fetch?: UsageApiFetch;
}
function buildClaudePlan(
@@ -254,8 +254,8 @@ function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] {
return limits;
}
function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] {
const windows: ProviderUsageWindow[] = [];
function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] {
const windows: UsageWindow[] = [];
for (const spec of UNSCOPED_WINDOWS) {
const window = resp[spec.field];
if (!window) continue;
@@ -272,7 +272,7 @@ function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] {
return windows;
}
function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] {
function scopedWindows(limits: ScopedLimit[]): UsageWindow[] {
const taken = new Set<string>();
return limits.map((limit) => {
const id = uniqueWindowId(scopedWindowId(limit), taken);
@@ -337,38 +337,40 @@ export async function readClaudeKeychainCredentials(
return null;
}
export class ClaudeQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "claude";
readonly displayName = "Claude";
private readonly logger: Logger;
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: ProviderApiFetch;
private readonly fetchApi: UsageApiFetch;
constructor(options: ClaudeQuotaProviderOptions) {
this.logger = options.logger.child({ module: "claude-quota-provider" });
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<ProviderUsage> {
async fetchUsage(): Promise<UsageReport> {
const credentials = await this.readCredentials();
if (!credentials) {
return unavailableUsage(this);
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(this);
return unavailableUsage(accountKey);
}
const scoped = reconcileScopedLimits(
@@ -376,6 +378,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
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
@@ -384,7 +387,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
this.logger.warn("Claude usage response parsed but produced no windows");
}
const details: ProviderUsageDetail[] = [];
const details: UsageDetail[] = [];
const extraUsageEnabled = resp.extra_usage?.is_enabled;
if (extraUsageEnabled !== undefined) {
details.push({
@@ -395,14 +398,12 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
}
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: accountKey },
status: "available",
planLabel: plan,
planLabel: plan ?? undefined,
windows,
balances: [],
details,
error: null,
};
}
@@ -435,6 +436,8 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
}
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 (
@@ -0,0 +1,7 @@
import { z } from "zod";
export const inputSchema = z.union([
z.object({ configDir: z.string().min(1) }).strict(),
z.object({ accessToken: z.string().min(1) }).strict(),
z.object({}).strict(),
]);
export type UsageInput = z.infer<typeof inputSchema>;
@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 416" fill="currentColor"><path d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fill-rule="nonzero"/><path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,15 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { CopilotQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "copilot",
label: "GitHub Copilot",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
fetch: async (_input) => new CopilotQuotaProvider({ logger: console }).fetchUsage(),
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "copilot-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -1,11 +1,15 @@
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
import {
ApiOptionalStringSchema,
fetchProviderApi,
unavailableUsage,
type UsageReport,
type UsageDetail,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
const CopilotUsageResponseSchema = z.object({
copilot_plan: ApiOptionalStringSchema,
@@ -13,8 +17,8 @@ const CopilotUsageResponseSchema = z.object({
});
interface CopilotQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
logger: Console;
fetch?: UsageApiFetch;
}
async function readGithubCliToken(): Promise<string | null> {
@@ -37,26 +41,23 @@ async function readGithubCliToken(): Promise<string | null> {
return null;
}
export class CopilotQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "copilot";
readonly displayName = "GitHub Copilot";
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
export class CopilotQuotaProvider {
private readonly logger: Console;
private readonly fetchApi: UsageApiFetch;
constructor(options: CopilotQuotaProviderOptions) {
this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch;
}
async fetchUsage(): Promise<ProviderUsage> {
async fetchUsage(): Promise<UsageReport> {
const token =
process.env["COPILOT_TOKEN"] ||
process.env["GITHUB_TOKEN"] ||
process.env["GITHUB_PAT"] ||
(await readGithubCliToken());
if (!token) return unavailableUsage(this);
if (!token) return unavailableUsage();
const res = await fetchProviderApi(
this.fetchApi,
@@ -75,23 +76,21 @@ export class CopilotQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) {
this.logger.debug({ status: res.status }, "Copilot usage fetch failed");
return unavailableUsage(this);
return unavailableUsage();
}
const resp = CopilotUsageResponseSchema.parse(await res.json());
const details: ProviderUsageDetail[] = resp.quota_reset_date
const details: UsageDetail[] = resp.quota_reset_date
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
: [];
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: "default" },
status: "available",
planLabel: resp.copilot_plan || null,
planLabel: resp.copilot_plan || undefined,
windows: [],
balances: [],
details,
error: null,
};
}
}
@@ -0,0 +1,2 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">
<path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

@@ -0,0 +1,15 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { CursorQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "cursor",
label: "Cursor",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
fetch: async (_input) => new CursorQuotaProvider({ logger: console }).fetchUsage(),
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "cursor-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -2,10 +2,7 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import {
ApiNullableNumberSchema,
toneFromUsedPct,
@@ -13,7 +10,10 @@ import {
fetchProviderApi,
toIsoStringOrNull,
unavailableUsage,
} from "../usage.js";
type UsageReport,
type UsageBalance,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
// 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
@@ -63,8 +63,8 @@ const CursorAuthStatusSchema = z.object({
type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
interface CursorQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
logger: Console;
fetch?: UsageApiFetch;
homeDir?: string;
}
@@ -113,7 +113,7 @@ function cursorTokenFromDb(db: CursorStateDatabase): string | null {
return null;
}
async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> {
async function readCursorTokenFromSqlite(homeDir: string, logger: Console): Promise<string | null> {
const dbPaths: string[] = [];
if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
@@ -162,7 +162,7 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi
async function readCursorTokenFromAuthJson(
homeDir: string,
logger: Logger,
logger: Console,
): Promise<string | null> {
const path = join(homeDir, ".config", "cursor", "auth.json");
if (!existsSync(path)) return null;
@@ -175,12 +175,9 @@ async function readCursorTokenFromAuthJson(
}
}
export class CursorQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "cursor";
readonly displayName = "Cursor";
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
export class CursorQuotaProvider {
private readonly logger: Console;
private readonly fetchApi: UsageApiFetch;
private readonly homeDir: string;
constructor(options: CursorQuotaProviderOptions) {
@@ -189,14 +186,14 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
this.homeDir = options.homeDir ?? homedir();
}
async fetchUsage(): Promise<ProviderUsage> {
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(this);
if (!token) return unavailableUsage();
const res = await fetchProviderApi(
this.fetchApi,
@@ -214,12 +211,12 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) {
this.logger.debug({ status: res.status }, "Cursor usage fetch failed");
return unavailableUsage(this);
return unavailableUsage();
}
const resp = CursorUsageResponseSchema.parse(await res.json());
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
const balances: ProviderUsageBalance[] = [];
const balances: UsageBalance[] = [];
if (resp.planUsage) {
const totalSpend = centsToDollars(resp.planUsage.totalSpend);
const remaining = centsToDollars(resp.planUsage.remaining);
@@ -237,14 +234,12 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
}
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: "default" },
status: "available",
planLabel: null,
planLabel: undefined,
windows: [],
balances,
details: [],
error: null,
};
}
}
@@ -0,0 +1,2 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>

After

Width:  |  Height:  |  Size: 277 B

@@ -0,0 +1,15 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { GrokQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "grok",
label: "Grok",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
fetch: async (_input) => new GrokQuotaProvider({ logger: console }).fetchUsage(),
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "grok-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -1,14 +1,7 @@
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageWindow,
} from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import {
ApiNumberSchema,
ApiOptionalStringSchema,
@@ -17,7 +10,11 @@ import {
fetchProviderApi,
unavailableUsage,
windowFromUsedPct,
} from "../usage.js";
type UsageReport,
type UsageWindow,
type UsageBalance,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
const GrokUsageResponseSchema = z.object({
config: z
@@ -49,8 +46,8 @@ const GrokUsageResponseSchema = z.object({
});
interface GrokQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
logger: Console;
fetch?: UsageApiFetch;
/** Override home directory (tests). Production uses os.homedir(). */
homeDir?: string;
}
@@ -82,7 +79,7 @@ export function extractGrokTokenFromAuth(auth: unknown): string | null {
function grokMonthlyCreditBalance(
response: z.infer<typeof GrokUsageResponseSchema>,
): ProviderUsageBalance | null {
): UsageBalance | null {
const limit = response.config?.monthlyLimit?.val ?? null;
const used = response.config?.used?.val ?? response.usage?.creditUsage ?? null;
if (limit === null && used === null) return null;
@@ -97,9 +94,7 @@ function grokMonthlyCreditBalance(
};
}
function grokUsageWindow(
response: z.infer<typeof GrokUsageResponseSchema>,
): ProviderUsageWindow | null {
function grokUsageWindow(response: z.infer<typeof GrokUsageResponseSchema>): UsageWindow | null {
const percent = response.config?.creditUsagePercent;
if (typeof percent !== "number") return null;
const period = response.config?.currentPeriod;
@@ -113,12 +108,9 @@ function grokUsageWindow(
});
}
export class GrokQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "grok";
readonly displayName = "Grok";
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
export class GrokQuotaProvider {
private readonly logger: Console;
private readonly fetchApi: UsageApiFetch;
private readonly homeDir: string | undefined;
constructor(options: GrokQuotaProviderOptions) {
@@ -127,11 +119,11 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
this.homeDir = options.homeDir;
}
async fetchUsage(): Promise<ProviderUsage> {
async fetchUsage(): Promise<UsageReport> {
const token =
process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken());
if (!token) return unavailableUsage(this);
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.
@@ -149,22 +141,21 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) {
this.logger.debug({ status: res.status }, "Grok usage fetch failed");
return unavailableUsage(this);
return unavailableUsage();
}
const resp = GrokUsageResponseSchema.parse(await res.json());
const balance = grokMonthlyCreditBalance(resp);
const window = grokUsageWindow(resp);
if (window) window.headline = true;
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: "default" },
status: "available",
planLabel: null,
planLabel: undefined,
windows: window ? [window] : [],
balances: balance ? [balance] : [],
details: [],
error: null,
};
}
@@ -0,0 +1,2 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
+42
View File
@@ -1,6 +1,13 @@
import type { PluginServerContribution } from "@getpaseo/plugin/server";
import { fileURLToPath } from "node:url";
import codexUsageSource from "./codex-usage-source/index.server.js";
import claudeUsageSource from "./claude-usage-source/index.server.js";
import copilotUsageSource from "./copilot-usage-source/index.server.js";
import cursorUsageSource from "./cursor-usage-source/index.server.js";
import grokUsageSource from "./grok-usage-source/index.server.js";
import kimiUsageSource from "./kimi-usage-source/index.server.js";
import minimaxUsageSource from "./minimax-usage-source/index.server.js";
import zaiUsageSource from "./zai-usage-source/index.server.js";
export interface InternalPlugin {
id: string;
@@ -9,9 +16,44 @@ export interface InternalPlugin {
}
export const INTERNAL_PLUGINS: InternalPlugin[] = [
{
id: "claude-usage-source",
directory: fileURLToPath(new URL("./claude-usage-source/", import.meta.url)),
contribute: claudeUsageSource,
},
{
id: "codex-usage-source",
directory: fileURLToPath(new URL("./codex-usage-source/", import.meta.url)),
contribute: codexUsageSource,
},
{
id: "copilot-usage-source",
directory: fileURLToPath(new URL("./copilot-usage-source/", import.meta.url)),
contribute: copilotUsageSource,
},
{
id: "cursor-usage-source",
directory: fileURLToPath(new URL("./cursor-usage-source/", import.meta.url)),
contribute: cursorUsageSource,
},
{
id: "grok-usage-source",
directory: fileURLToPath(new URL("./grok-usage-source/", import.meta.url)),
contribute: grokUsageSource,
},
{
id: "kimi-usage-source",
directory: fileURLToPath(new URL("./kimi-usage-source/", import.meta.url)),
contribute: kimiUsageSource,
},
{
id: "minimax-usage-source",
directory: fileURLToPath(new URL("./minimax-usage-source/", import.meta.url)),
contribute: minimaxUsageSource,
},
{
id: "zai-usage-source",
directory: fileURLToPath(new URL("./zai-usage-source/", import.meta.url)),
contribute: zaiUsageSource,
},
];
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

@@ -0,0 +1,15 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { KimiQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "kimi",
label: "Kimi",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
fetch: async (_input) => new KimiQuotaProvider({ logger: console }).fetchUsage(),
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "kimi-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -1,10 +1,7 @@
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import {
ApiNumberSchema,
ApiOptionalStringSchema,
@@ -12,7 +9,10 @@ import {
toneFromUsedPct,
unavailableUsage,
windowFromUsedPct,
} from "../usage.js";
type UsageReport,
type UsageWindow,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
@@ -165,7 +165,7 @@ function windowFromFields(input: {
id: string;
label: string;
fields: KimiUsageFields;
}): ProviderUsageWindow {
}): UsageWindow {
const usedPct = usedPctFromFields(input.fields);
return windowFromUsedPct({
id: input.id,
@@ -189,10 +189,10 @@ function uniqueWindowId(baseId: string, seenIds: Set<string>): string {
function kimiUsageWindowsFromPayload(
payload: unknown,
logger: Pick<Logger, "debug">,
): ProviderUsageWindow[] {
logger: Pick<Console, "debug">,
): UsageWindow[] {
const response = KimiUsageResponseSchema.parse(payload);
const windows: ProviderUsageWindow[] = [];
const windows: UsageWindow[] = [];
const seenWindowIds = new Set<string>();
const usage = parseUsageFields(response.usage);
@@ -250,48 +250,44 @@ type KimiAuth = z.infer<typeof KimiAuthSchema>;
type KimiCredentials = KimiAuth & { access_token: string };
interface KimiQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
logger: Console;
fetch?: UsageApiFetch;
homeDir?: string;
}
export class KimiQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "kimi";
readonly displayName = "Kimi";
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
export class KimiQuotaProvider {
private readonly logger: Console;
private readonly fetchApi: UsageApiFetch;
private readonly homeDir?: string;
constructor(options: KimiQuotaProviderOptions) {
this.logger = options.logger.child({ module: "kimi-quota-provider" });
this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir;
}
async fetchUsage(): Promise<ProviderUsage> {
async fetchUsage(): Promise<UsageReport> {
const credentials = await this.readCredentials();
if (!credentials) return unavailableUsage(this);
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(this);
return unavailableUsage();
}
const windows = kimiUsageWindowsFromPayload(await res.json(), this.logger);
if (windows[0]) windows[0].headline = true;
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: "default" },
status: "available",
planLabel: null,
planLabel: undefined,
windows,
balances: [],
details: [],
error: null,
};
}
@@ -0,0 +1,2 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { MiniMaxQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "minimax",
label: "MiniMax",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
fetch: async (_input) => new MiniMaxQuotaProvider({ logger: console }).fetchUsage(),
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "minimax-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -1,17 +1,17 @@
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import {
ApiNumberSchema,
ApiOptionalStringSchema,
fetchProviderApi,
unavailableUsage,
windowFromUsedPct,
} from "../usage.js";
type UsageReport,
type UsageWindow,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io";
const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com";
@@ -74,8 +74,8 @@ interface MiniMaxResolvedAuth {
}
interface MiniMaxQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
logger: Console;
fetch?: UsageApiFetch;
configPath?: string;
credentialsPath?: string;
env?: NodeJS.ProcessEnv;
@@ -105,16 +105,13 @@ function epochMsToIso(value: number | null | undefined): string | null {
return new Date(value).toISOString();
}
function toneForStatus(status: number | null | undefined): ProviderUsageWindow["tone"] {
function toneForStatus(status: number | null | undefined): UsageWindow["tone"] {
if (status === 2) return "danger";
if (status === 3) return "default";
return "ok";
}
function toIntervalWindow(
modelName: string,
model: MiniMaxModelRemain,
): ProviderUsageWindow | null {
function toIntervalWindow(modelName: string, model: MiniMaxModelRemain): UsageWindow | null {
const total = model.current_interval_total_count ?? null;
const used = model.current_interval_usage_count ?? null;
const remainingPercent = model.current_interval_remaining_percent ?? null;
@@ -135,7 +132,7 @@ function toIntervalWindow(
});
}
function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderUsageWindow | null {
function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): UsageWindow | null {
const total = model.current_weekly_total_count ?? null;
const used = model.current_weekly_usage_count ?? null;
const remainingPercent = model.current_weekly_remaining_percent ?? null;
@@ -155,12 +152,9 @@ function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderU
});
}
export class MiniMaxQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "minimax";
readonly displayName = "MiniMax";
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
export class MiniMaxQuotaProvider {
private readonly logger: Console;
private readonly fetchApi: UsageApiFetch;
private readonly configPath: string;
private readonly credentialsPath: string;
private readonly env: NodeJS.ProcessEnv;
@@ -175,9 +169,9 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher {
this.now = options.now ?? Date.now;
}
async fetchUsage(): Promise<ProviderUsage> {
async fetchUsage(): Promise<UsageReport> {
const auth = await this.resolveAuth();
if (!auth) return unavailableUsage(this);
if (!auth) return unavailableUsage();
const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, {
headers: {
@@ -188,7 +182,7 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) {
this.logger.debug({ status: res.status }, "MiniMax usage fetch failed");
return unavailableUsage(this);
return unavailableUsage();
}
const resp = MiniMaxQuotaResponseSchema.parse(await res.json());
@@ -199,12 +193,12 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher {
{ statusCode, statusMsg: resp.base_resp?.status_msg },
"MiniMax usage unavailable",
);
return unavailableUsage(this);
return unavailableUsage();
}
const models = resp.model_remains ?? [];
const windows: ProviderUsageWindow[] = [];
const windows: UsageWindow[] = [];
for (const model of models) {
const name = model.model_name ?? "token-plan";
const intervalWindow = toIntervalWindow(name, model);
@@ -212,16 +206,15 @@ export class MiniMaxQuotaProvider implements ProviderUsageFetcher {
const weeklyWindow = toWeeklyWindow(name, model);
if (weeklyWindow) windows.push(weeklyWindow);
}
if (windows[0]) windows[0].headline = true;
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: "default" },
status: windows.length > 0 ? "available" : "unavailable",
planLabel: null,
planLabel: undefined,
windows,
balances: [],
details: [],
error: null,
};
}
@@ -0,0 +1,2 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
@@ -5,16 +5,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderUsage } from "../../server/messages.js";
import type { ProviderUsageFetcher } from "./provider.js";
import { ClaudeQuotaProvider } from "./providers/claude.js";
import { CodexQuotaProvider } from "./providers/codex.js";
import { CopilotQuotaProvider } from "./providers/copilot.js";
import { CursorQuotaProvider } from "./providers/cursor.js";
import { GrokQuotaProvider } from "./providers/grok.js";
import { KimiQuotaProvider } from "./providers/kimi.js";
import { MiniMaxQuotaProvider } from "./providers/minimax.js";
import { ZaiQuotaProvider } from "./providers/zai.js";
import { ProviderUsageService } from "./service.js";
import { ClaudeQuotaProvider } from "./claude-usage-source/server/usage.js";
import { CopilotQuotaProvider } from "./copilot-usage-source/server/usage.js";
import { CursorQuotaProvider } from "./cursor-usage-source/server/usage.js";
import { GrokQuotaProvider } from "./grok-usage-source/server/usage.js";
import { KimiQuotaProvider } from "./kimi-usage-source/server/usage.js";
import { MiniMaxQuotaProvider } from "./minimax-usage-source/server/usage.js";
import { ZaiQuotaProvider } from "./zai-usage-source/server/usage.js";
function writeClaudeCredentials(
dir: string,
@@ -31,13 +28,6 @@ function writeClaudeCredentials(
);
}
function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_codex"): void {
writeFileSync(
join(dir, "auth.json"),
JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken } }),
);
}
function kimiCredentialPath(dir: string): string {
return join(dir, "credentials", "kimi-code.json");
}
@@ -127,18 +117,6 @@ function makeClaudeResponse(
};
}
function makeCodexResponse(overrides: object = {}) {
return {
plan_type: "plus",
email: "user@example.com",
rate_limit: {
primary_window: { used_percent: 42, reset_at: 1_748_812_800 },
secondary_window: { used_percent: 8, reset_at: 1_749_072_000 },
},
...overrides,
};
}
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
return vi.fn(async (url: RequestInfo | URL) => {
const key = url.toString();
@@ -166,187 +144,12 @@ function createLogger() {
return logger as never;
}
function usageFetcher(usage: ProviderUsage): ProviderUsageFetcher {
return {
providerId: usage.providerId,
displayName: usage.displayName,
fetchUsage: async () => usage,
};
}
function findProvider(result: { providers: ProviderUsage[] }, providerId: string): ProviderUsage {
const provider = result.providers.find((candidate) => candidate.providerId === providerId);
if (!provider) {
throw new Error(`Missing provider ${providerId}`);
}
if (!provider) throw new Error(`Missing provider ${providerId}`);
return provider;
}
describe("ProviderUsageService", () => {
it("returns arbitrary registered providers and windows as normalized usage data", async () => {
const service = new ProviderUsageService({
logger: createLogger(),
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
fetchers: [
usageFetcher({
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [
{
id: "biweekly",
label: "Biweekly",
usedPct: 23,
remainingPct: 77,
resetsAt: "2026-07-03T00:00:00.000Z",
},
],
}),
],
});
await expect(service.listUsage()).resolves.toEqual({
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [
{
id: "biweekly",
label: "Biweekly",
usedPct: 23,
remainingPct: 77,
resetsAt: "2026-07-03T00:00:00.000Z",
},
],
},
],
});
});
it("caches usage until forced to refresh", async () => {
let now = Date.parse("2026-06-19T00:00:00.000Z");
let calls = 0;
const service = new ProviderUsageService({
logger: createLogger(),
now: () => now,
cacheTtlMs: 60_000,
fetchers: [
{
providerId: "claude",
displayName: "Claude",
fetchUsage: async () => {
calls += 1;
return {
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: calls }],
};
},
},
],
});
const first = await service.listUsage();
now += 30_000;
const cached = await service.listUsage();
const refreshed = await service.listUsage({ forceRefresh: true });
expect(calls).toBe(2);
expect(cached).toBe(first);
expect(refreshed.providers[0]?.windows[0]?.usedPct).toBe(2);
});
it("deduplicates concurrent cache misses", async () => {
let calls = 0;
let resolveUsage: ((usage: ProviderUsage) => void) | null = null;
const service = new ProviderUsageService({
logger: createLogger(),
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
fetchers: [
{
providerId: "claude",
displayName: "Claude",
fetchUsage: () => {
calls += 1;
return new Promise<ProviderUsage>((resolve) => {
resolveUsage = resolve;
});
},
},
],
});
const first = service.listUsage();
const second = service.listUsage();
expect(calls).toBe(1);
resolveUsage?.({
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: 12 }],
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBe(secondResult);
expect(calls).toBe(1);
});
it("isolates one provider error without dropping other providers", async () => {
const service = new ProviderUsageService({
logger: createLogger(),
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
fetchers: [
{
providerId: "claude",
displayName: "Claude",
fetchUsage: async () => {
throw new Error("Claude auth expired");
},
},
usageFetcher({
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
}),
],
});
await expect(service.listUsage()).resolves.toEqual({
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "error",
planLabel: null,
windows: [],
balances: [],
details: [],
error: "Claude auth expired",
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
},
],
});
});
});
describe("real provider usage fetchers", () => {
let claudeHome: string;
let codexHome: string;
@@ -411,49 +214,64 @@ describe("real provider usage fetchers", () => {
const logger = createLogger();
const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) =>
fetchApi(url, init)) as typeof fetch;
return new ProviderUsageService({
logger,
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
fetchers: [
new ClaudeQuotaProvider({
logger,
claudeHome,
claudeKeychainReader: options.keychain ?? (async () => null),
platform: options.platform,
fetch: fetchThroughTestDouble,
}),
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
homeDir: options.cursorHomeDir,
}),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
// Match Kimi: inject temp HOME so nested auth-file tests work on Windows
// (os.homedir() uses USERPROFILE there and ignores process.env.HOME).
homeDir,
}),
new KimiQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
// Never leave this undefined: the provider would fall back to os.homedir() and
// read — and now write — the developer's real Kimi credentials.
homeDir: options.kimiHomeDir ?? homeDir,
}),
new MiniMaxQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
configPath: options.miniMaxConfigPath ?? join(homeDir, ".mmx", "config.json"),
credentialsPath:
options.miniMaxCredentialsPath ?? join(homeDir, ".mmx", "credentials.json"),
}),
],
cacheTtlMs: 0,
});
const fetchers = [
new ClaudeQuotaProvider({
logger,
claudeHome,
claudeKeychainReader: options.keychain ?? (async () => null),
platform: options.platform,
fetch: fetchThroughTestDouble,
}),
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
homeDir: options.cursorHomeDir,
}),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
// Match Kimi: inject temp HOME so nested auth-file tests work on Windows
// (os.homedir() uses USERPROFILE there and ignores process.env.HOME).
homeDir,
}),
new KimiQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
// Never leave this undefined: the provider would fall back to os.homedir() and
// read — and now write — the developer's real Kimi credentials.
homeDir: options.kimiHomeDir ?? homeDir,
}),
new MiniMaxQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
configPath: options.miniMaxConfigPath ?? join(homeDir, ".mmx", "config.json"),
credentialsPath:
options.miniMaxCredentialsPath ?? join(homeDir, ".mmx", "credentials.json"),
}),
];
return {
listUsage: async () => ({
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: await Promise.all(
fetchers.map(async (fetcher) => {
const report = await fetcher.fetchUsage();
const id = fetcher.constructor.name.replace("QuotaProvider", "").toLowerCase();
return {
providerId: id === "minimax" ? "minimax" : id,
displayName: id,
status: report.status,
planLabel: report.planLabel ?? null,
windows: report.windows,
balances: report.balances ?? [],
details: report.details ?? [],
error: report.error ?? null,
} satisfies ProviderUsage;
}),
),
}),
};
}
it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => {
@@ -574,91 +392,6 @@ describe("real provider usage fetchers", () => {
);
});
it("fetches Codex windows and coerces string credit balances", async () => {
writeCodexAuth(codexHome, "at_codex_valid");
fetchApi = mockFetch(
new Map([
[
"https://chatgpt.com/backend-api/wham/usage",
() =>
jsonResponse(
makeCodexResponse({
code_review_rate_limit: null,
credits: { balance: "0" },
}),
),
],
]),
);
const result = await service().listUsage();
const codex = findProvider(result, "codex");
expect(codex).toMatchObject({
status: "available",
planLabel: "plus",
windows: expect.arrayContaining([
expect.objectContaining({ id: "session", usedPct: 42 }),
expect.objectContaining({ id: "weekly", usedPct: 8 }),
]),
balances: [expect.objectContaining({ id: "credits", remaining: 0 })],
});
});
it("treats a Codex HTML usage response as auth failure", async () => {
writeCodexAuth(codexHome, "at_codex_stale");
fetchApi = mockFetch(
new Map([
[
"https://chatgpt.com/backend-api/wham/usage",
() => new Response("<html>Login</html>", { status: 200 }),
],
]),
);
const result = await service().listUsage();
expect(findProvider(result, "codex").status).toBe("unavailable");
});
it("returns unavailable on 401 without refreshing or rewriting auth.json", async () => {
// Regression: the fetcher used to refresh the token and rewrite auth.json
// through a schema that dropped id_token (and OPENAI_API_KEY/last_refresh),
// leaving the file unparseable by the Codex CLI and forcing a re-login.
const authPath = join(codexHome, "auth.json");
writeFileSync(
authPath,
JSON.stringify({
OPENAI_API_KEY: null,
tokens: {
id_token: "id_codex",
access_token: "at_codex_stale",
refresh_token: "rt_codex_valid",
account_id: "acct_codex",
},
last_refresh: "2026-07-04T20:35:00Z",
}),
);
const before = readFileSync(authPath, "utf8");
let usageCalls = 0;
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
const endpoint = url.toString();
if (endpoint === "https://chatgpt.com/backend-api/wham/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, "codex").status).toBe("unavailable");
expect(usageCalls).toBe(1);
// The auth file must be left byte-for-byte untouched for the Codex CLI to own.
expect(readFileSync(authPath, "utf8")).toBe(before);
});
it("fetches Copilot usage from COPILOT_TOKEN", async () => {
process.env["COPILOT_TOKEN"] = "copilot_test_token";
fetchApi = mockFetch(
@@ -1426,37 +1159,6 @@ describe("usage bars escalate as they fill", () => {
const usage = await claudeAt(utilization);
expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]);
});
it("a Codex window can reach danger, not just warning", async () => {
writeCodexAuth(codexHome, "at_codex");
const usage = await new CodexQuotaProvider({
logger: createLogger(),
codexHome,
fetch: mockFetch(
new Map([
[
"https://chatgpt.com/backend-api/wham/usage",
() =>
jsonResponse(
makeCodexResponse({
rate_limit: {
primary_window: { used_percent: 12, reset_at: 1_748_812_800 },
secondary_window: { used_percent: 96, reset_at: 1_749_072_000 },
},
}),
),
],
]),
),
}).fetchUsage();
expect(usage.windows).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "session", tone: "ok" }),
expect.objectContaining({ id: "weekly", tone: "danger" }),
]),
);
});
});
// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

@@ -0,0 +1,15 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { ZaiQuotaProvider } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "zai",
label: "Z.ai",
icon: "icon.svg",
input: inputSchema,
discover: async () => [{}],
fetch: async (_input) => new ZaiQuotaProvider({ logger: console }).fetchUsage(),
});
return () => {};
}
@@ -0,0 +1 @@
{ "id": "zai-usage-source", "requirements": { "paseo": ">=0.9.2" } }
@@ -1,8 +1,12 @@
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
import {
ApiOptionalStringSchema,
fetchProviderApi,
unavailableUsage,
type UsageReport,
type UsageDetail,
type UsageApiFetch,
} from "@getpaseo/plugin/server/usage";
const ZaiUsageResponseSchema = z.object({
data: z
@@ -18,25 +22,22 @@ const ZaiUsageResponseSchema = z.object({
});
interface ZaiQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
logger: Console;
fetch?: UsageApiFetch;
}
export class ZaiQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "zai";
readonly displayName = "Z.ai";
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
export class ZaiQuotaProvider {
private readonly logger: Console;
private readonly fetchApi: UsageApiFetch;
constructor(options: ZaiQuotaProviderOptions) {
this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch;
}
async fetchUsage(): Promise<ProviderUsage> {
async fetchUsage(): Promise<UsageReport> {
const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"];
if (!token) return unavailableUsage(this);
if (!token) return unavailableUsage();
const res = await fetchProviderApi(
this.fetchApi,
@@ -51,14 +52,14 @@ export class ZaiQuotaProvider implements ProviderUsageFetcher {
if (!res.ok) {
this.logger.debug({ status: res.status }, "Z.ai usage fetch failed");
return unavailableUsage(this);
return unavailableUsage();
}
const resp = ZaiUsageResponseSchema.parse(await res.json());
const sub = resp.data?.[0];
if (!sub) return unavailableUsage(this);
if (!sub) return unavailableUsage();
const details: ProviderUsageDetail[] = [];
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) {
@@ -66,14 +67,12 @@ export class ZaiQuotaProvider implements ProviderUsageFetcher {
}
return {
providerId: this.providerId,
displayName: this.displayName,
account: { key: "default" },
status: "available",
planLabel: sub.productName || null,
planLabel: sub.productName || undefined,
windows: [],
balances: [],
details,
error: null,
};
}
}
@@ -0,0 +1,2 @@
import { z } from "zod";
export const inputSchema = z.object({}).strict();
@@ -146,6 +146,10 @@ export class PluginService {
return this.usageSources.listReports(options);
}
listLegacyUsage() {
return this.usageSources.listLegacyUsage();
}
subscribeProviderRegistrations(listener: () => void): () => void {
this.providerListeners.add(listener);
return () => this.providerListeners.delete(listener);
@@ -83,3 +83,48 @@ test("turns fetch and discovery failures into error reports", async () => {
"error",
]);
});
test("maps discovered reports to the legacy provider usage response", async () => {
const registry = new UsageSourceRegistry(() => Date.parse("2026-06-19T00:00:00.000Z"));
registry.register({
id: "fixture",
label: "Fixture Source",
discover: async () => [{}],
fetch: async () => ({
account: { key: "one" },
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, headline: true }],
}),
});
registry.register({
id: "missing",
label: "Missing Source",
discover: async () => [{}],
fetch: async () => ({ account: { key: "default" }, status: "unavailable", windows: [] }),
});
expect(await registry.listLegacyUsage()).toEqual({
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "fixture",
displayName: "Fixture Source",
status: "available",
planLabel: null,
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, headline: true }],
balances: [],
details: [],
error: null,
},
{
providerId: "missing",
displayName: "Missing Source",
status: "unavailable",
planLabel: null,
windows: [],
balances: [],
details: [],
error: null,
},
],
});
});
@@ -1,5 +1,9 @@
import { createHash } from "node:crypto";
import { UsageReportSchema, type UsageReportEntry } from "@getpaseo/protocol/messages";
import {
UsageReportSchema,
type UsageReportEntry,
type ProviderUsage,
} from "@getpaseo/protocol/messages";
export interface UsageSource {
id: string;
@@ -48,6 +52,24 @@ export class UsageSourceRegistry {
return [...unique.values()];
}
// COMPAT(providerUsageList): added in v0.9.2, remove after 2027-03-26.
async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> {
const reports = await this.listReports();
return {
fetchedAt: new Date(this.now()).toISOString(),
providers: reports.map((entry) => ({
providerId: entry.sourceId,
displayName: entry.sourceLabel,
status: entry.report.status,
planLabel: entry.report.planLabel ?? null,
windows: entry.report.windows,
balances: entry.report.balances ?? [],
details: entry.report.details ?? [],
error: entry.report.error ?? null,
})),
};
}
fetch(
sourceId: string,
input: unknown,
@@ -522,6 +522,7 @@ test("routes plugin requests and releases its owned catalog subscription on clea
catalog: () => [{ id: "example", clientBundle: "bundle" }],
invokePluginRpc: async () => ({ ok: true }),
listUsageReports: async () => [],
listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }),
};
const session = createSessionForTest({ messages, pluginRuntime });
+10 -5
View File
@@ -1,7 +1,11 @@
import { searchTimeline } from "./agent/chat-search/index.js";
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
import type { SessionEventSubscription, UsageReportEntry } from "@getpaseo/protocol/messages";
import type {
SessionEventSubscription,
UsageReportEntry,
ProviderUsage,
} from "@getpaseo/protocol/messages";
import { relative } from "node:path";
import { isAbsolute } from "node:path";
import { CreationService } from "./creation/index.js";
@@ -226,7 +230,6 @@ import {
type GitHubService,
} from "../services/github-service.js";
import type { ForgeService } from "../services/forge-service.js";
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
import {
resolveWorkspaceRootAgent,
summarizeFetchWorkspacesEntries,
@@ -507,6 +510,7 @@ export interface SessionOptions {
catalog(): Array<{ id: string; clientBundle: string }>;
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
listUsageReports(options?: { forceRefresh?: boolean }): Promise<UsageReportEntry[]>;
listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>;
};
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
mcpBaseUrl?: string | null;
@@ -515,7 +519,6 @@ export interface SessionOptions {
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
providerUsageService: ProviderUsageService;
hubExecutionAgents?: HubExecutionAgents;
hubRelationships?: HubRelationshipManagement;
serviceProxy?: ServiceProxySubsystem;
@@ -828,7 +831,6 @@ export class Session {
tts,
terminalManager,
providerSnapshotManager,
providerUsageService,
serviceProxy,
scriptRuntimeStore,
workspaceSetupSnapshots,
@@ -985,7 +987,10 @@ export class Session {
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
},
providerSnapshotManager,
providerUsageService,
listLegacyUsage: async () => {
if (!pluginRuntime) throw new Error("Plugin runtime is unavailable");
return pluginRuntime.listLegacyUsage();
},
logger: this.sessionLogger,
});
this.agentConfigSession = new AgentConfigSession({
@@ -14,7 +14,6 @@ import {
type ProviderSnapshotTransition,
} from "../../agent/provider-snapshot-manager.js";
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
import { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
@@ -24,7 +23,7 @@ interface MakeOptions {
supportsCustomModeIcons?: boolean;
supportsCompactProviderSnapshots?: boolean;
snapshot?: Partial<ProviderSnapshotManager>;
usage?: { [K in keyof ProviderUsageService]?: unknown };
usage?: () => Promise<{ fetchedAt: string; providers: [] }>;
host?: Partial<ProviderCatalogSessionHost>;
}
@@ -73,7 +72,8 @@ function makeSubsystem(options: MakeOptions = {}) {
const subsystem = new ProviderCatalogSession({
host,
providerSnapshotManager,
providerUsageService: createStub<ProviderUsageService>(options.usage ?? {}),
listLegacyUsage:
options.usage ?? (async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] })),
logger: pino({ level: "silent" }),
});
function pushSnapshotChange(
@@ -301,10 +301,8 @@ describe("ProviderCatalogSession", () => {
it("surfaces a usage-list failure as an rpc_error envelope", async () => {
const { subsystem, emitted } = makeSubsystem({
usage: {
listUsage: async () => {
throw new Error("quota service down");
},
usage: async () => {
throw new Error("quota service down");
},
});
@@ -388,10 +386,7 @@ it("announces shared content without retransmitting models or hashing discovery
new ProviderCatalogSession({
providerSnapshotManager: manager,
logger: pino({ level: "silent" }),
providerUsageService: new ProviderUsageService({
logger: pino({ level: "silent" }),
fetchers: [],
}),
listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }),
host: {
emit(message) {
emitted.push(message);
@@ -19,7 +19,7 @@ import {
type ProviderSnapshotEntry,
} from "../../agent/agent-sdk-types.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
import type { ProviderUsage } from "@getpaseo/protocol/messages";
import { expandTilde } from "../../../utils/path.js";
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
@@ -56,7 +56,7 @@ export interface ProviderCatalogSessionHost {
export interface ProviderCatalogSessionOptions {
host: ProviderCatalogSessionHost;
providerSnapshotManager: ProviderSnapshotManager;
providerUsageService: ProviderUsageService;
listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>;
logger: pino.Logger;
}
@@ -70,14 +70,14 @@ export interface ProviderCatalogSessionOptions {
export class ProviderCatalogSession {
private readonly host: ProviderCatalogSessionHost;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly providerUsageService: ProviderUsageService;
private readonly listLegacyUsage: ProviderCatalogSessionOptions["listLegacyUsage"];
private readonly logger: pino.Logger;
private unsubscribeSnapshotEvents: (() => void) | null = null;
constructor(options: ProviderCatalogSessionOptions) {
this.host = options.host;
this.providerSnapshotManager = options.providerSnapshotManager;
this.providerUsageService = options.providerUsageService;
this.listLegacyUsage = options.listLegacyUsage;
this.logger = options.logger;
}
@@ -491,7 +491,7 @@ export class ProviderCatalogSession {
msg: Extract<SessionInboundMessage, { type: "provider.usage.list.request" }>,
): Promise<void> {
try {
const usage = await this.providerUsageService.listUsage();
const usage = await this.listLegacyUsage();
this.host.emit({
type: "provider.usage.list.response",
payload: {
@@ -90,7 +90,6 @@ import {
type WebSocketRuntimeCounters,
type WebSocketRuntimeDiagnosticSnapshot,
} from "./websocket/runtime-metrics.js";
import { ProviderUsageService } from "../services/quota-fetcher/service.js";
import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js";
import {
CLIENT_SHUTDOWN_RPC_REASON,
@@ -584,7 +583,6 @@ export class VoiceAssistantWebSocketServer {
private eventLoopDelayMonitor: ReturnType<typeof monitorEventLoopDelay> | null = null;
private unsubscribeSpeechReadiness: (() => void) | null = null;
private unsubscribeDaemonConfigChange: (() => void) | null = null;
private readonly providerUsageService: ProviderUsageService;
private unsubscribeTerminalActivity: (() => void) | null = null;
private readonly browserToolsBroker: BrowserToolsBroker | null;
private readonly hubRelationships: HubRelationshipManagement | null;
@@ -754,10 +752,6 @@ export class VoiceAssistantWebSocketServer {
});
});
this.providerUsageService = new ProviderUsageService({
logger: this.logger,
});
this.wss = this.createWebSocketServer(server, wsConfig, auth);
this.startRuntimeMetricsInterval();
this.startApplicationSocketLeaseInterval();
@@ -1497,7 +1491,6 @@ export class VoiceAssistantWebSocketServer {
tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
providerUsageService: this.providerUsageService,
hubExecutionAgents: options.hubExecutionAgents,
hubRelationships: options.hubRelationships,
serviceProxy: this.serviceProxy ?? undefined,
@@ -1,62 +0,0 @@
import type {
ProviderUsageFetcher,
ProviderUsageFetcherFactoryOptions,
ProviderUsageFetcherManifestEntry,
} from "./provider.js";
import { ClaudeQuotaProvider } from "./providers/claude.js";
import { CodexQuotaProvider } from "./providers/codex.js";
import { CopilotQuotaProvider } from "./providers/copilot.js";
import { CursorQuotaProvider } from "./providers/cursor.js";
import { GrokQuotaProvider } from "./providers/grok.js";
import { KimiQuotaProvider } from "./providers/kimi.js";
import { MiniMaxQuotaProvider } from "./providers/minimax.js";
import { ZaiQuotaProvider } from "./providers/zai.js";
export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry[] = [
{
providerId: "claude",
create: (options) =>
new ClaudeQuotaProvider({
logger: options.logger,
fetch: options.fetch,
}),
},
{
providerId: "codex",
create: (options) =>
new CodexQuotaProvider({
logger: options.logger,
fetch: options.fetch,
}),
},
{
providerId: "copilot",
create: (options) => new CopilotQuotaProvider({ logger: options.logger, fetch: options.fetch }),
},
{
providerId: "cursor",
create: (options) => new CursorQuotaProvider({ logger: options.logger, fetch: options.fetch }),
},
{
providerId: "zai",
create: (options) => new ZaiQuotaProvider({ logger: options.logger, fetch: options.fetch }),
},
{
providerId: "grok",
create: (options) => new GrokQuotaProvider({ logger: options.logger, fetch: options.fetch }),
},
{
providerId: "kimi",
create: (options) => new KimiQuotaProvider({ logger: options.logger, fetch: options.fetch }),
},
{
providerId: "minimax",
create: (options) => new MiniMaxQuotaProvider({ logger: options.logger, fetch: options.fetch }),
},
];
export function createProviderUsageFetchers(
options: ProviderUsageFetcherFactoryOptions,
): ProviderUsageFetcher[] {
return PROVIDER_USAGE_FETCHERS.map((entry) => entry.create(options));
}
@@ -1,20 +0,0 @@
import type { Logger } from "pino";
import type { ProviderUsage } from "../../server/messages.js";
export type ProviderApiFetch = typeof fetch;
export interface ProviderUsageFetcher {
readonly providerId: string;
readonly displayName: string;
fetchUsage(): Promise<ProviderUsage>;
}
export interface ProviderUsageFetcherFactoryOptions {
logger: Logger;
fetch?: ProviderApiFetch;
}
export interface ProviderUsageFetcherManifestEntry {
readonly providerId: string;
create(options: ProviderUsageFetcherFactoryOptions): ProviderUsageFetcher;
}
@@ -1,87 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./claude.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" },
});
});
});
@@ -1,214 +0,0 @@
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageWindow,
} from "../../../server/messages.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
import {
ApiNumberSchema,
balanceToneFromRemaining,
toneFromUsedPct,
fetchProviderApi,
unavailableUsage,
windowFromUsedPct,
} from "../usage.js";
const CodexAuthSchema = z.object({
tokens: z
.object({
access_token: z.string().optional(),
refresh_token: z.string().optional(),
account_id: z.string().optional(),
})
.optional(),
});
const CodexWindowSchema = z.object({
used_percent: ApiNumberSchema.optional(),
reset_at: ApiNumberSchema.optional(),
});
const CodexUsageResponseSchema = z.object({
plan_type: z.string().optional(),
email: z.string().optional(),
rate_limit: z
.object({
primary_window: CodexWindowSchema.nullish(),
secondary_window: CodexWindowSchema.nullish(),
})
.nullish(),
code_review_rate_limit: z
.object({
primary_window: CodexWindowSchema.nullish(),
})
.nullish(),
credits: z
.object({
has_credits: z.boolean().optional(),
unlimited: z.boolean().optional(),
balance: ApiNumberSchema.optional(),
})
.nullish(),
});
type CodexAuth = z.infer<typeof CodexAuthSchema>;
type CodexWindow = z.infer<typeof CodexWindowSchema>;
type CodexUsageResponse = z.infer<typeof CodexUsageResponseSchema>;
interface CodexQuotaProviderOptions {
logger: Logger;
codexHome?: string;
fetch?: ProviderApiFetch;
}
function codexWindow(
window: CodexWindow | null | undefined,
): { usedPct: number; resetsAt: string | null } | null {
if (!window) return null;
return {
usedPct: window.used_percent ?? 0,
resetsAt: window.reset_at != null ? new Date(window.reset_at * 1000).toISOString() : null,
};
}
export class CodexQuotaProvider implements ProviderUsageFetcher {
readonly providerId = "codex";
readonly displayName = "Codex";
private readonly codexHome: string;
private readonly fetchApi: ProviderApiFetch;
constructor(options: CodexQuotaProviderOptions) {
this.codexHome = options.codexHome || process.env["CODEX_HOME"] || join(homedir(), ".codex");
this.fetchApi = options.fetch ?? fetch;
}
async fetchUsage(): Promise<ProviderUsage> {
const auth = await this.readCodexAuth();
const accessToken = auth?.tokens?.access_token;
if (!auth || !accessToken) {
return unavailableUsage(this);
}
const { account_id } = auth.tokens ?? {};
const resp = await this.callCodexApi(accessToken, account_id);
if (resp === "NEEDS_AUTH") {
// Read-only on credentials; the Codex CLI owns refresh. See docs/providers.md.
return unavailableUsage(this);
}
return this.toUsage(resp);
}
private toUsage(resp: CodexUsageResponse): ProviderUsage {
const session = codexWindow(resp.rate_limit?.primary_window);
const weekly = codexWindow(resp.rate_limit?.secondary_window);
const codeReview = codexWindow(resp.code_review_rate_limit?.primary_window);
const windows: ProviderUsageWindow[] = [];
if (session) {
windows.push(
windowFromUsedPct({
id: "session",
label: "Session",
utilizationPct: session.usedPct,
resetsAt: session.resetsAt,
tone: toneFromUsedPct(session.usedPct),
}),
);
}
if (weekly) {
windows.push(
windowFromUsedPct({
id: "weekly",
label: "Weekly",
utilizationPct: weekly.usedPct,
resetsAt: weekly.resetsAt,
tone: toneFromUsedPct(weekly.usedPct),
}),
);
}
if (codeReview) {
windows.push(
windowFromUsedPct({
id: "code_review",
label: "Code review",
utilizationPct: codeReview.usedPct,
resetsAt: codeReview.resetsAt,
tone: toneFromUsedPct(codeReview.usedPct),
}),
);
}
const balances: ProviderUsageBalance[] = [];
if (resp.credits?.balance !== undefined) {
balances.push({
id: "credits",
label: "Credits",
remaining: resp.credits.balance,
unit: "usd",
tone: balanceToneFromRemaining(resp.credits.balance),
});
}
return {
providerId: this.providerId,
displayName: this.displayName,
status: "available",
planLabel: resp.plan_type ?? null,
windows,
balances,
details: [],
error: null,
};
}
private async readCodexAuth(): Promise<CodexAuth | null> {
const candidates = [
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
join(homedir(), ".config", "codex", "auth.json"),
join(this.codexHome, "auth.json"),
];
for (const path of candidates) {
if (!existsSync(path)) continue;
try {
const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
if (auth.tokens?.access_token) return auth;
} catch {
continue;
}
}
return null;
}
private async callCodexApi(
token: string,
accountId?: string,
): Promise<CodexUsageResponse | "NEEDS_AUTH"> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
};
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
const res = await fetchProviderApi(
this.fetchApi,
"https://chatgpt.com/backend-api/wham/usage",
{
headers,
},
);
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
if (!res.ok) throw new Error(`Codex usage API returned ${res.status}`);
const text = await res.text();
if (text.trim().startsWith("<")) return "NEEDS_AUTH";
return CodexUsageResponseSchema.parse(JSON.parse(text));
}
}
@@ -1,89 +0,0 @@
import type { Logger } from "pino";
import type { ProviderUsage } from "../../server/messages.js";
import { createProviderUsageFetchers } from "./manifest.js";
import type { ProviderApiFetch, ProviderUsageFetcher } from "./provider.js";
import { unavailableUsage } from "./usage.js";
export interface ProviderUsageServiceOptions {
logger: Logger;
fetchers?: ProviderUsageFetcher[];
fetch?: ProviderApiFetch;
cacheTtlMs?: number;
now?: () => number;
}
export interface ProviderUsageListResult {
fetchedAt: string;
providers: ProviderUsage[];
}
const DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS = 5 * 60 * 1000;
export class ProviderUsageService {
private readonly logger: Logger;
private readonly fetchers: ProviderUsageFetcher[];
private readonly cacheTtlMs: number;
private readonly now: () => number;
private cached: { fetchedAtMs: number; result: ProviderUsageListResult } | null = null;
private inFlight: Promise<ProviderUsageListResult> | null = null;
constructor(options: ProviderUsageServiceOptions) {
this.logger = options.logger.child({ module: "provider-usage-service" });
this.fetchers =
options.fetchers ??
createProviderUsageFetchers({
logger: this.logger,
fetch: options.fetch,
});
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS;
this.now = options.now ?? Date.now;
}
async listUsage(options?: { forceRefresh?: boolean }): Promise<ProviderUsageListResult> {
const nowMs = this.now();
if (
!options?.forceRefresh &&
this.cached &&
nowMs - this.cached.fetchedAtMs < this.cacheTtlMs
) {
return this.cached.result;
}
if (this.inFlight) {
return this.inFlight;
}
const request = this.fetchFreshUsage(nowMs);
this.inFlight = request;
try {
return await request;
} finally {
if (this.inFlight === request) {
this.inFlight = null;
}
}
}
private async fetchFreshUsage(nowMs: number): Promise<ProviderUsageListResult> {
const settled = await Promise.allSettled(this.fetchers.map((fetcher) => fetcher.fetchUsage()));
const providers = settled.map((result, index) => {
const fetcher = this.fetchers[index];
if (result.status === "fulfilled") {
return result.value;
}
this.logger.debug(
{ err: result.reason, providerId: fetcher.providerId },
"Provider usage fetch failed",
);
return unavailableUsage({
providerId: fetcher.providerId,
displayName: fetcher.displayName,
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
});
});
const result = { fetchedAt: new Date(nowMs).toISOString(), providers };
this.cached = { fetchedAtMs: nowMs, result };
return result;
}
}
@@ -1,51 +0,0 @@
import { describe, expect, it } from "vitest";
import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js";
describe("toneFromUsedPct", () => {
// Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what
// the client applies when a window arrives without a tone.
it.each([
[0, "ok"],
[69.9, "ok"],
[70, "warning"],
[90, "warning"],
[90.1, "danger"],
[100, "danger"],
[150, "danger"],
])("%s%% used is %s", (usedPct, expected) => {
expect(toneFromUsedPct(usedPct)).toBe(expected);
});
it("is neutral when the percentage is unknown", () => {
expect(toneFromUsedPct(null)).toBe("default");
expect(toneFromUsedPct(undefined)).toBe("default");
});
});
describe("usedPctOf", () => {
it("computes a percentage of the limit", () => {
expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2);
});
it("is unknown when either side is missing", () => {
expect(usedPctOf(null, 100)).toBeNull();
expect(usedPctOf(50, null)).toBeNull();
});
// A zero limit would divide to Infinity and render as a full red bar.
it("is unknown when the limit is zero or negative", () => {
expect(usedPctOf(50, 0)).toBeNull();
expect(usedPctOf(50, -1)).toBeNull();
});
});
describe("balanceToneFromRemaining", () => {
// Kept for balances with no limit, where no percentage can be computed. It only
// escalates at exhaustion, which is why anything with a limit should use
// toneFromUsedPct instead.
it("stays ok until nothing is left", () => {
expect(balanceToneFromRemaining(0.01)).toBe("ok");
expect(balanceToneFromRemaining(0)).toBe("danger");
expect(balanceToneFromRemaining(null)).toBe("default");
});
});
@@ -1,111 +0,0 @@
import { z } from "zod";
import type {
ProviderUsage,
ProviderUsageBalance,
ProviderUsageTone,
ProviderUsageWindow,
} from "../../server/messages.js";
import type { ProviderApiFetch } from "./provider.js";
const PROVIDER_HTTP_TIMEOUT_MS = 15_000;
export const ApiNumberSchema = z.coerce.number().finite();
export const ApiNullableNumberSchema = z.preprocess(
(value) => (value == null ? null : value),
ApiNumberSchema.nullable(),
);
export const ApiOptionalStringSchema = z.preprocess(
(value) => (value == null ? undefined : value),
z.coerce.string().optional(),
);
export function fetchProviderApi(
fetchApi: ProviderApiFetch,
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> {
return fetchApi(input, {
...init,
signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS),
});
}
export function unavailableUsage(provider: {
providerId: string;
displayName: string;
error?: string | null;
}): ProviderUsage {
return {
providerId: provider.providerId,
displayName: provider.displayName,
status: provider.error ? "error" : "unavailable",
planLabel: null,
windows: [],
balances: [],
details: [],
error: provider.error ?? null,
};
}
export function windowFromUsedPct(input: {
id: string;
label: string;
utilizationPct: number | null | undefined;
resetsAt?: string | null;
tone?: ProviderUsageWindow["tone"];
}): ProviderUsageWindow {
const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null;
const window: ProviderUsageWindow = {
id: input.id,
label: input.label,
usedPct,
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
resetsAt: input.resetsAt ?? null,
};
if (input.tone) {
window.tone = input.tone;
}
return window;
}
/**
* The tone scale for anything measured against a known limit, windows and balances alike.
*
* Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the
* client falls back to when a window arrives without a tone. Healthy is "ok" rather than
* "default" because that is what every provider setting a tone has always sent, and it is
* what the bars render today below their thresholds.
*/
export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsageTone {
if (typeof usedPct !== "number") return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
return "ok";
}
/**
* Tone for a balance with no known limit, where a percentage cannot be computed and the
* only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists:
* this one stays "ok" until the balance is completely spent.
*/
export function balanceToneFromRemaining(
remaining: number | null | undefined,
): ProviderUsageBalance["tone"] {
if (typeof remaining !== "number") return "default";
if (remaining <= 0) return "danger";
return "ok";
}
/** Percentage of a limit consumed, or null when either side is unknown. */
export function usedPctOf(
used: number | null | undefined,
limit: number | null | undefined,
): number | null {
if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null;
return (used / limit) * 100;
}
export function toIsoStringOrNull(timestampMs: number): string | null {
const date = new Date(timestampMs);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
+28
View File
@@ -304,6 +304,34 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
### Usage sources
**Requires Paseo 0.9.2 or newer.** Server plugins can register a usage source with `server.registerUsageSource()` and import its types and helpers from `@getpaseo/plugin/server/usage`.
```ts
import { z } from "zod";
import type { PluginServerContext } from "@getpaseo/plugin/server";
const input = z.object({ account: z.string() });
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
id: "example-usage",
label: "Example",
icon: "icon.svg",
input,
discover: async () => [{ account: "default" }],
fetch: async (value) => {
const { account } = input.parse(value);
return { account: { key: account }, status: "available", windows: [] };
},
});
return () => {};
}
```
`discover()` supplies host accounts for `usage.list_reports`; return `[]` when none are configured. The daemon validates input against `input` inside the plugin process, caches each source/input result for five minutes, and deduplicates reports by source and `account.key`. `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. Set `headline: true` on the window to show first. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above.
## Entry point and cleanup
Each present entry default-exports one contribution function and returns cleanup. Client entries