mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
feat: live multi-provider quota panel (#1278)
* feat: live provider quota panel (Claude + Codex) Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address review feedback - restore quota trigger, guard NaN date - Re-add triggerFetch() on agent idle/error in agent status subscription - Guard formatResetsAtLabel against NaN from malformed API date strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve composer conflict and stub subscribe in websocket tests * feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety * feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics * security(quota): fix shell injection vulnerability by migrating exec to execFile * revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1 * Fix i18n resource test expectation * Fix quota tooltip empty-provider state * Preserve zero values in Grok quota * Fix empty quota fetch state * Ignore quota frames in relay reconnect tests * Persist refreshed Codex tokens to source auth file * fix(quota): read Claude OAuth credentials from the macOS login Keychain On macOS, Claude Code stores its OAuth credential in the login Keychain (generic-password item, service "Claude Code-credentials"), not in ~/.claude/.credentials.json — that file usually does not exist there, so the Claude provider's existsSync check failed and macOS users silently got no Claude quota. Read it via the macOS `security` CLI (its ACL only trusts /usr/bin/security to decrypt; a native Keychain read would prompt), and stay read-only there since Claude Code owns the refresh/persist. Linux and Windows keep using ~/.claude/.credentials.json unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix quota keychain timeout and local host reconciliation * Fix terminal notification test quota stub * Ignore quota frames in terminal notification tests * fix(quota): bypass Claude token refresh on macOS Keychain-backed logins * fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1 * fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1 * fix(client): handle and dispatch provider_quota event in DaemonClient * fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding * fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs * fix(desktop): fix concurrently command invocation on Windows in dev.ps1 * fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows * Add on-demand provider usage views * Fix Cursor usage billing dates * Move provider usage renderer coverage to e2e * Use provider manifest for quota fetchers * Add provider usage fetch timeouts * Reshape provider usage fetchers --------- Co-authored-by: ABorakati <ABorakati@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> Co-authored-by: lumingjun <lumingjun@bytedance.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
ABorakati
Mohamed Boudra
lumingjun
parent
2b0740ff84
commit
acea7f3d24
@@ -54,6 +54,21 @@ Boundary tests should assert observable behavior: cold reads may call provider a
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
- `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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
### 1. Create the provider class
|
||||
|
||||
Generated
+25
-51
@@ -11207,8 +11207,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11221,8 +11220,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11235,8 +11233,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11249,8 +11246,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11263,8 +11259,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11277,8 +11272,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11291,8 +11285,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11305,8 +11298,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11319,8 +11311,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11333,8 +11324,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11347,8 +11337,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11361,8 +11350,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11375,8 +11363,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11389,8 +11376,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11403,8 +11389,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11417,8 +11402,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11431,8 +11415,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11445,8 +11428,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11459,8 +11441,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11473,8 +11454,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11487,8 +11467,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11501,8 +11480,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11515,8 +11493,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11529,8 +11506,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11543,8 +11519,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
@@ -18272,7 +18247,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ProviderUsage } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
interface ProviderUsageFixturePayload {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
export interface ProviderUsageFixture {
|
||||
requestCount(): number;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withProviderUsageFeature(message: WebSocketMessage): string | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const payload = maybeEnvelope.message?.payload;
|
||||
if (
|
||||
maybeEnvelope.type !== "session" ||
|
||||
maybeEnvelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
...maybeEnvelope,
|
||||
message: {
|
||||
...maybeEnvelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...(typeof payload.features === "object" && payload.features !== null
|
||||
? payload.features
|
||||
: {}),
|
||||
providerUsageList: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installProviderUsageFixture(
|
||||
page: Page,
|
||||
payloads: ProviderUsageFixturePayload[],
|
||||
): Promise<ProviderUsageFixture> {
|
||||
let requests = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
function notifyWaiters() {
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (requests >= waiter.count) {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiters.push(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function payloadForRequest(): ProviderUsageFixturePayload {
|
||||
const index = Math.min(requests - 1, payloads.length - 1);
|
||||
const payload = payloads[index];
|
||||
if (!payload) {
|
||||
throw new Error("Provider usage fixture requires at least one payload.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "provider.usage.list.request") {
|
||||
requests += 1;
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId !== "string") {
|
||||
throw new Error("provider.usage.list.request missing requestId");
|
||||
}
|
||||
const payload = payloadForRequest();
|
||||
notifyWaiters();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
providers: payload.providers,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
requestCount() {
|
||||
return requests;
|
||||
},
|
||||
waitForRequestCount(count: number) {
|
||||
if (requests >= count) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
waiters.push({ count, resolve });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const SECTION_LABELS = {
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -374,6 +374,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-usage")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { installProviderUsageFixture } from "./helpers/provider-usage";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { openSettingsHostSection } from "./helpers/settings";
|
||||
|
||||
test.describe("provider usage settings", () => {
|
||||
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
sourceLabel: "OpenUsage 0.6.27",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
|
||||
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh invalidates and refetches usage", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
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 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("one provider error does not collapse the usage list", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Error", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("71%")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { installProviderUsageFixture } from "./helpers/provider-usage";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-usage-tooltip-",
|
||||
title: "Provider usage tooltip e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("provider usage tooltip", () => {
|
||||
test("fetches usage when the context tooltip opens and renders the active provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: 42,
|
||||
remainingPct: 58,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
|
||||
await page.getByTestId("context-window-meter").hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Test plan")).toBeVisible();
|
||||
await expect(page.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("42%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 41 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
serverId?: string;
|
||||
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
|
||||
provider?: string | null;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 16;
|
||||
@@ -41,16 +48,6 @@ function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function formatSessionCost(value: number): string | null {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
@@ -75,15 +72,52 @@ function getMeterColors(
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
function getMeterGeometry(showPercentage: boolean) {
|
||||
if (showPercentage) {
|
||||
return {
|
||||
svgSize: COMPACT_SVG_SIZE,
|
||||
center: COMPACT_CENTER,
|
||||
radius: COMPACT_RADIUS,
|
||||
strokeWidth: COMPACT_STROKE_WIDTH,
|
||||
circumference: COMPACT_CIRCUMFERENCE,
|
||||
containerStyle: styles.containerWithLabel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
svgSize: SVG_SIZE,
|
||||
center: CENTER,
|
||||
radius: RADIUS,
|
||||
strokeWidth: STROKE_WIDTH,
|
||||
circumference: CIRCUMFERENCE,
|
||||
containerStyle: styles.container,
|
||||
};
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
serverId,
|
||||
provider,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
|
||||
serverId ?? null,
|
||||
{ enabled: isTooltipOpen },
|
||||
);
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage();
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
@@ -91,22 +125,25 @@ export function ContextWindowMeter({
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
|
||||
const center = showPercentage ? COMPACT_CENTER : CENTER;
|
||||
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
|
||||
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
|
||||
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
|
||||
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
|
||||
getMeterGeometry(showPercentage);
|
||||
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<Tooltip
|
||||
open={isTooltipOpen}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("contextWindow.accessibility", {
|
||||
percentage: roundedPercentage,
|
||||
@@ -162,6 +199,7 @@ export function ContextWindowMeter({
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -194,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 200,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
@@ -196,6 +196,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
|
||||
model: agent?.model ?? null,
|
||||
provider: agent?.provider ?? null,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -205,6 +207,8 @@ function renderContextWindowMeter(
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
showPercentage: boolean,
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
): ReactElement | null {
|
||||
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
|
||||
return null;
|
||||
@@ -215,6 +219,8 @@ function renderContextWindowMeter(
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
showPercentage={showPercentage}
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1643,8 +1649,17 @@ export function Composer({
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
),
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
|
||||
[
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
|
||||
@@ -1394,6 +1394,7 @@ export const ar: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
|
||||
@@ -1401,6 +1401,7 @@ export const en = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
|
||||
@@ -1430,6 +1430,7 @@ export const es: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
|
||||
@@ -1433,6 +1433,7 @@ export const fr: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
|
||||
@@ -1422,6 +1422,7 @@ export const ru: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
|
||||
@@ -1377,6 +1377,7 @@ export const zhCN: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
||||
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
|
||||
|
||||
interface ResolvedBalance {
|
||||
amountText: string;
|
||||
usedPct: number | null;
|
||||
}
|
||||
|
||||
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
|
||||
const { used, remaining, limit, unit } = balance;
|
||||
if (limit != null && limit > 0) {
|
||||
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
|
||||
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
|
||||
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
|
||||
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
|
||||
}
|
||||
if (remaining != null) {
|
||||
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
|
||||
}
|
||||
if (used != null) {
|
||||
return { amountText: formatAmount(used, unit), usedPct: null };
|
||||
}
|
||||
return { amountText: "—", usedPct: null };
|
||||
}
|
||||
|
||||
function fillToneStyle(tone: ProviderUsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
|
||||
const { amountText, usedPct } = resolveBalance(balance);
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
[usedPct, tone],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{balance.label}
|
||||
</Text>
|
||||
<Text style={styles.value}>
|
||||
{amountText}
|
||||
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
{usedPct != null ? (
|
||||
<View style={styles.track}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: 3,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ProviderUsageBalanceBar } from "./balance-bar";
|
||||
import { formatAgo } from "./format";
|
||||
import type { ProviderUsage } from "./types";
|
||||
import { ProviderUsageWindowBar } from "./window-bar";
|
||||
|
||||
interface ProviderUsageIconProps {
|
||||
iconKey: string;
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
|
||||
|
||||
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
function statusText(usage: ProviderUsage): string | null {
|
||||
if (usage.status === "available") return null;
|
||||
return usage.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function footerText(usage: ProviderUsage): string | null {
|
||||
const updated = formatAgo(usage.fetchedAt);
|
||||
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export function ProviderUsageCard({
|
||||
usage,
|
||||
compact = false,
|
||||
}: {
|
||||
usage: ProviderUsage;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const status = statusText(usage);
|
||||
const footer = footerText(usage);
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
[compact],
|
||||
);
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
usage.status === "available" && styles.statusDotAvailable,
|
||||
usage.status === "error" && styles.statusDotError,
|
||||
],
|
||||
[usage.status],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.header}>
|
||||
<ThemedProviderUsageIcon
|
||||
iconKey={usage.providerId}
|
||||
size={16}
|
||||
uniProps={foregroundIconColor}
|
||||
/>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{usage.displayName}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
<ProviderUsageWindowBar key={window.id} window={window} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{details.length > 0 ? (
|
||||
<View style={styles.details}>
|
||||
{details.map((detail) => (
|
||||
<View key={detail.id} style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{detail.label}
|
||||
</Text>
|
||||
<Text style={styles.detailValue} numberOfLines={1}>
|
||||
{detail.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{footer ? (
|
||||
<Text style={styles.footer} numberOfLines={1}>
|
||||
{footer}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
containerPadded: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
containerCompact: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
name: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusDotAvailable: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
statusDotError: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
bars: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
details: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
detailLabel: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
detailValue: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
footer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
// User-facing copy for the provider-usage surfaces, centralized so localization
|
||||
// is a single-file change. INTEGRATION: move these into the i18n resources
|
||||
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
|
||||
// call sites once the feature is wired to data. Kept inline for now because the
|
||||
// surfaces are not yet mounted and the locale files are being edited elsewhere.
|
||||
export const providerUsageCopy = {
|
||||
title: "Plan usage",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
errorTitle: "Unable to load usage",
|
||||
hostUnavailable: "Connect to this host to see provider usage",
|
||||
hostUpgradeRequired: "Update the host to see provider usage",
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
tooltipLoading: "Loading plan usage…",
|
||||
} as const;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import type { ProviderUsageBalanceUnit } from "./types";
|
||||
|
||||
export function clampPct(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function formatPct(value: number): string {
|
||||
return `${Math.round(clampPct(value))}%`;
|
||||
}
|
||||
|
||||
function relativeDuration(iso: string): string | null {
|
||||
const diffMs = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs <= 0) return "now";
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h`;
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
export function formatResetLabel(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso);
|
||||
if (!rel) return null;
|
||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||
}
|
||||
|
||||
export function formatAgo(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs < 60_000) return "just now";
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) return `${diffDays}d ago`;
|
||||
if (diffHours > 0) return `${diffHours}h ago`;
|
||||
return `${diffMinutes}m ago`;
|
||||
}
|
||||
|
||||
export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
|
||||
switch (unit) {
|
||||
case "usd":
|
||||
return `$${value.toFixed(2)}`;
|
||||
case "tokens":
|
||||
return formatTokenCount(value);
|
||||
default:
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Fragment } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import type { ProviderUsage } from "./types";
|
||||
|
||||
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
{providers.map((usage, index) => (
|
||||
<Fragment key={usage.providerId}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<ProviderUsageCard usage={usage} />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,96 @@
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import { ProviderUsageList } from "./list";
|
||||
import type { ProviderUsageView } from "./types";
|
||||
|
||||
export function ProviderUsageSettingsSection({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={RefreshCw}
|
||||
loading={busy}
|
||||
onPress={onRefresh}
|
||||
accessibilityLabel={providerUsageCopy.refresh}
|
||||
>
|
||||
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
|
||||
</Button>
|
||||
),
|
||||
[busy, onRefresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={providerUsageCopy.title}
|
||||
testID="provider-usage-card"
|
||||
trailing={refreshButton}
|
||||
>
|
||||
<ProviderUsageBody view={view} onRefresh={onRefresh} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUsageBody({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{providerUsageCopy.retry}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.payload.providers.length === 0) {
|
||||
return (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProviderUsageList providers={view.payload.providers} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ProviderUsageTone } from "./types";
|
||||
|
||||
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
|
||||
if (usedPct == null) return "default";
|
||||
if (usedPct > 90) return "danger";
|
||||
if (usedPct >= 70) return "warning";
|
||||
return "default";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsage, ProviderUsageView } from "./types";
|
||||
|
||||
function matchProvider(
|
||||
providers: ProviderUsage[],
|
||||
activeProviderId: string | null | undefined,
|
||||
): ProviderUsage | null {
|
||||
if (!activeProviderId) return null;
|
||||
const target = activeProviderId.toLowerCase();
|
||||
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
|
||||
}
|
||||
|
||||
// Renders the active agent's provider usage inside the context-meter tooltip.
|
||||
// Returns nothing when the active provider has no usage entry, so the meter's
|
||||
// own context section stays the whole tooltip.
|
||||
export function ProviderUsageTooltipSection({
|
||||
view,
|
||||
activeProviderId,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
activeProviderId: string | null | undefined;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.error}>{view.message}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const usage = matchProvider(view.payload.providers, activeProviderId);
|
||||
if (!usage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<ProviderUsageCard usage={usage} compact />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[1] + 1,
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageListResponseMessage,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
};
|
||||
|
||||
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
|
||||
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
|
||||
export type ProviderUsageView =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
|
||||
|
||||
export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
type ProviderUsageClient = Pick<DaemonClient, "listProviderUsage">;
|
||||
|
||||
export function providerUsageQueryKey(serverId: string | null | undefined) {
|
||||
return ["providerUsage", serverId ?? ""] as const;
|
||||
}
|
||||
|
||||
async function fetchProviderUsage(client: ProviderUsageClient): Promise<ProviderUsageListPayload> {
|
||||
return client.listProviderUsage();
|
||||
}
|
||||
|
||||
interface UseProviderUsageOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useProviderUsage(
|
||||
serverId: string | null | undefined,
|
||||
options: UseProviderUsageOptions = {},
|
||||
): {
|
||||
view: ProviderUsageView;
|
||||
refresh: () => Promise<void>;
|
||||
canFetch: boolean;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const supportsProviderUsage = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
|
||||
);
|
||||
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
|
||||
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
|
||||
const enabled = Boolean((options.enabled ?? true) && canFetch);
|
||||
|
||||
const queryFn = useCallback(async () => {
|
||||
if (!client) {
|
||||
throw new Error(providerUsageCopy.clientUnavailable);
|
||||
}
|
||||
return fetchProviderUsage(client);
|
||||
}, [client]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled,
|
||||
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
if (!canFetch) {
|
||||
return;
|
||||
}
|
||||
await queryClient.fetchQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
|
||||
});
|
||||
}, [canFetch, queryClient, queryFn, queryKey]);
|
||||
|
||||
const view = useMemo<ProviderUsageView>(() => {
|
||||
if (!serverId || !client || !isConnected) {
|
||||
return { kind: "error", message: providerUsageCopy.hostUnavailable };
|
||||
}
|
||||
if (!supportsProviderUsage) {
|
||||
return { kind: "error", message: providerUsageCopy.hostUpgradeRequired };
|
||||
}
|
||||
if (query.data) {
|
||||
return {
|
||||
kind: "ready",
|
||||
payload: query.data,
|
||||
isRefreshing: query.isFetching,
|
||||
};
|
||||
}
|
||||
if (query.isError) {
|
||||
return {
|
||||
kind: "error",
|
||||
message: query.error instanceof Error ? query.error.message : String(query.error),
|
||||
};
|
||||
}
|
||||
return { kind: "loading" };
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
query.data,
|
||||
query.error,
|
||||
query.isError,
|
||||
query.isFetching,
|
||||
serverId,
|
||||
supportsProviderUsage,
|
||||
]);
|
||||
|
||||
return { view, refresh, canFetch };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatPct, formatResetLabel } from "./format";
|
||||
import { deriveTone } from "./tone";
|
||||
import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
|
||||
|
||||
function resolveUsedPct(window: ProviderUsageWindow): number | null {
|
||||
if (window.usedPct != null) return window.usedPct;
|
||||
if (window.remainingPct != null) return 100 - window.remainingPct;
|
||||
return null;
|
||||
}
|
||||
|
||||
function fillToneStyle(tone: ProviderUsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
|
||||
const usedPct = resolveUsedPct(window);
|
||||
const tone = window.tone ?? deriveTone(usedPct);
|
||||
|
||||
const fillWidth = clampPct(usedPct ?? 0);
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }],
|
||||
[fillWidth, tone],
|
||||
);
|
||||
|
||||
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
|
||||
const trailing = isAtRisk
|
||||
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
|
||||
: formatResetLabel(window.resetsAt);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{window.label}
|
||||
</Text>
|
||||
<Text style={styles.value}>
|
||||
{usedPct != null ? formatPct(usedPct) : "—"}
|
||||
{trailing ? (
|
||||
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.track}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: 3,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
atRisk: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Network,
|
||||
Bot,
|
||||
Boxes,
|
||||
Gauge,
|
||||
Keyboard,
|
||||
Stethoscope,
|
||||
Info,
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
HostAgentsPage,
|
||||
HostSettingsPage,
|
||||
HostProvidersPage,
|
||||
HostUsagePage,
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
@@ -171,6 +173,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
|
||||
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
|
||||
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
|
||||
];
|
||||
@@ -188,6 +191,8 @@ function renderHostSettingsContent(
|
||||
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||
case "providers":
|
||||
return <HostProvidersPage serverId={view.serverId} />;
|
||||
case "usage":
|
||||
return <HostUsagePage serverId={view.serverId} />;
|
||||
case "terminals":
|
||||
return <HostTerminalsPage serverId={view.serverId} />;
|
||||
case "host":
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -306,6 +308,24 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function HostUsagePage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refreshProviderUsage();
|
||||
}, [refreshProviderUsage]);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostSettingsPage({
|
||||
serverId,
|
||||
onHostRemoved,
|
||||
|
||||
@@ -183,6 +183,7 @@ describe("host settings section slugs", () => {
|
||||
expect(normalizeHostSectionSlug("agents")).toBe("agents");
|
||||
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
|
||||
expect(normalizeHostSectionSlug("providers")).toBe("providers");
|
||||
expect(normalizeHostSectionSlug("usage")).toBe("usage");
|
||||
expect(normalizeHostSectionSlug("host")).toBe("host");
|
||||
});
|
||||
|
||||
|
||||
@@ -417,6 +417,7 @@ export const HOST_SECTION_SLUGS = [
|
||||
"agents",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"usage",
|
||||
"terminals",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
@@ -4070,6 +4070,81 @@ test("dispatches terminals_changed events to typed listeners", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends provider.usage.list.request and resolves provider.usage.list.response", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const usagePromise = client.listProviderUsage({ requestId: "usage-1" });
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.request",
|
||||
requestId: "usage-1",
|
||||
},
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: "usage-1",
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(usagePromise).resolves.toEqual({
|
||||
requestId: "usage-1",
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("sends close_items_request and resolves close_items_response", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ProviderUsageListResponseMessage,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
AgentRewindResponseMessage,
|
||||
@@ -346,6 +347,7 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
|
||||
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
|
||||
type ReadProjectConfigPayload = Extract<
|
||||
@@ -3812,6 +3814,16 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listProviderUsage(options?: { requestId?: string }): Promise<ProviderUsageListPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "provider.usage.list.request",
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
|
||||
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
|
||||
async listCommands(
|
||||
|
||||
@@ -4,6 +4,7 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
|
||||
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
|
||||
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||
$env:PATH = "$RootDir\node_modules\.bin;$env:PATH"
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
@@ -78,18 +79,27 @@ if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" }
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if ($PaseoHomeManaged) {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
$env:TMP_CFG_PATH = "$($env:PASEO_HOME)/config.json"
|
||||
$env:TMP_CFG_PORT = $DevDaemonPort
|
||||
$TmpScript = [System.IO.Path]::GetTempFileName() + ".js"
|
||||
$ScriptContent = @"
|
||||
const fs = require('fs');
|
||||
const path = process.env.TMP_CFG_PATH;
|
||||
const port = process.env.TMP_CFG_PORT;
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, 'utf8')); } catch(e) {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.listen = '127.0.0.1:' + port;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
cfg.daemon.cors.allowedOrigins = ['*'];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$($env:PASEO_HOME)/config.json" $DevDaemonPort
|
||||
"@
|
||||
Set-Content -Path $TmpScript -Value $ScriptContent
|
||||
node $TmpScript
|
||||
Remove-Item $TmpScript -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\TMP_CFG_PATH -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\TMP_CFG_PORT -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host " (custom PASEO_HOME - leaving its config.json untouched)"
|
||||
}
|
||||
@@ -107,9 +117,9 @@ Write-Host @"
|
||||
"@
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
& "$RootDir\node_modules\.bin\concurrently" `
|
||||
concurrently `
|
||||
--kill-others `
|
||||
--names "metro,electron" `
|
||||
--prefix-colors "magenta,cyan" `
|
||||
"cd `"$AppDir`" && `$env:PASEO_WEB_PLATFORM = `"electron`"; npx expo start --port $($env:EXPO_PORT)" `
|
||||
"cd `"$AppDir`" && cross-env PASEO_WEB_PLATFORM=electron npx expo start --port $($env:EXPO_PORT)" `
|
||||
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
|
||||
|
||||
@@ -133,6 +133,93 @@ describe("workspace descriptor message compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider usage list message contract", () => {
|
||||
test("accepts the usage list request as a namespaced correlated RPC", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "provider.usage.list.request",
|
||||
requestId: "usage-1",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "provider.usage.list.request",
|
||||
requestId: "usage-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts new providers and new usage windows as normalized data", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: "usage-2",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
resetsAt: "2026-07-03T00:00:00.000Z",
|
||||
tone: "ok",
|
||||
},
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: "credits",
|
||||
label: "Credits",
|
||||
remaining: 120,
|
||||
unit: "credits",
|
||||
},
|
||||
],
|
||||
details: [{ id: "region", label: "Region", value: "US" }],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("provider.usage.list.response");
|
||||
if (parsed.type !== "provider.usage.list.response") {
|
||||
throw new Error("Expected provider.usage.list.response");
|
||||
}
|
||||
expect(parsed.payload.providers[0]?.providerId).toBe("glm");
|
||||
expect(parsed.payload.providers[0]?.windows[0]?.label).toBe("Biweekly");
|
||||
});
|
||||
|
||||
test("keeps protocol numbers strict after API boundary normalization", () => {
|
||||
const parsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: "usage-3",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: "7",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent detach RPC", () => {
|
||||
test("parses the namespaced detach request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -1201,6 +1201,11 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderUsageListRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.usage.list.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ResumeAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("resume_agent_request"),
|
||||
handle: AgentPersistenceHandleSchema,
|
||||
@@ -2029,6 +2034,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
ProviderDiagnosticRequestMessageSchema,
|
||||
ProviderUsageListRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
ImportAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
@@ -2300,6 +2306,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
projectRemove: z.boolean().optional(),
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
worktreeRestore: z.boolean().optional(),
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: z.boolean().optional(),
|
||||
})
|
||||
@@ -3830,6 +3838,61 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]);
|
||||
export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]);
|
||||
|
||||
export const ProviderUsageWindowSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
usedPct: z.number().nullable().optional(),
|
||||
remainingPct: z.number().nullable().optional(),
|
||||
resetsAt: z.string().nullable().optional(),
|
||||
runsOutAt: z.string().nullable().optional(),
|
||||
shortfallPct: z.number().nullable().optional(),
|
||||
tone: ProviderUsageToneSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageBalanceSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
used: z.number().nullable().optional(),
|
||||
remaining: z.number().nullable().optional(),
|
||||
limit: z.number().nullable().optional(),
|
||||
unit: z.enum(["usd", "credits", "requests", "tokens"]),
|
||||
resetsAt: z.string().nullable().optional(),
|
||||
tone: ProviderUsageToneSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageDetailSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
tone: ProviderUsageToneSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageSchema = z.object({
|
||||
providerId: z.string(),
|
||||
displayName: z.string(),
|
||||
status: ProviderUsageStatusSchema,
|
||||
planLabel: z.string().nullable(),
|
||||
sourceLabel: z.string().nullable().optional(),
|
||||
fetchedAt: z.string().nullable().optional(),
|
||||
nextRefreshAt: z.string().nullable().optional(),
|
||||
windows: z.array(ProviderUsageWindowSchema),
|
||||
balances: z.array(ProviderUsageBalanceSchema).optional(),
|
||||
details: z.array(ProviderUsageDetailSchema).optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageListResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.usage.list.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
fetchedAt: z.string(),
|
||||
providers: z.array(ProviderUsageSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
const AgentSlashCommandSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -4093,6 +4156,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
RefreshProvidersSnapshotResponseMessageSchema,
|
||||
ProviderDiagnosticResponseMessageSchema,
|
||||
ProviderUsageListResponseMessageSchema,
|
||||
ListCommandsResponseSchema,
|
||||
ListTerminalsResponseSchema,
|
||||
TerminalsChangedSchema,
|
||||
@@ -4228,6 +4292,15 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer<
|
||||
export type ProviderDiagnosticResponseMessage = z.infer<
|
||||
typeof ProviderDiagnosticResponseMessageSchema
|
||||
>;
|
||||
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
|
||||
export type ProviderUsageStatus = z.infer<typeof ProviderUsageStatusSchema>;
|
||||
export type ProviderUsage = z.infer<typeof ProviderUsageSchema>;
|
||||
export type ProviderUsageWindow = z.infer<typeof ProviderUsageWindowSchema>;
|
||||
export type ProviderUsageBalance = z.infer<typeof ProviderUsageBalanceSchema>;
|
||||
export type ProviderUsageDetail = z.infer<typeof ProviderUsageDetailSchema>;
|
||||
export type ProviderUsageListResponseMessage = z.infer<
|
||||
typeof ProviderUsageListResponseMessageSchema
|
||||
>;
|
||||
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ResolveAgentCreateConfigInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
@@ -873,4 +873,13 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => {
|
||||
const resolved = resolveSnapshotCwd("C:");
|
||||
if (process.platform === "win32") {
|
||||
expect(resolved).toBe("C:\\");
|
||||
} else {
|
||||
expect(resolved).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -766,9 +766,16 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||
if (!trimmed) {
|
||||
return homedir();
|
||||
}
|
||||
const expanded =
|
||||
let expanded =
|
||||
trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed;
|
||||
return resolve(expanded);
|
||||
if (process.platform === "win32" && /^[A-Za-z]:$/.test(expanded)) {
|
||||
expanded = `${expanded}\\`;
|
||||
}
|
||||
let resolved = resolve(expanded);
|
||||
if (process.platform === "win32" && /^[A-Za-z]:$/.test(resolved)) {
|
||||
resolved = `${resolved}\\`;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function entriesToArray(
|
||||
|
||||
@@ -35,7 +35,7 @@ export class MockSlowProviderClient implements AgentClient {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
|
||||
}
|
||||
|
||||
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
||||
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
@@ -1996,21 +1998,33 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const launch = await this.resolvePiLaunch();
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.available) {
|
||||
return false;
|
||||
}
|
||||
const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null);
|
||||
if (!runtimeSession) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await runtimeSession.getAvailableModels()).length > 0;
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const launch = await this.resolvePiLaunch();
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.available) {
|
||||
return false;
|
||||
}
|
||||
const runtimeSession = await this.runtime
|
||||
.startSession({ cwd: homedir() })
|
||||
.catch(() => null);
|
||||
if (!runtimeSession) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await runtimeSession.getAvailableModels()).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
}
|
||||
})(),
|
||||
2000,
|
||||
"Pi availability check timed out",
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -436,6 +436,19 @@ describe("paseo daemon bootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses IPv6 listen targets correctly", () => {
|
||||
expect(parseListenString("[::1]:6767")).toEqual({
|
||||
type: "tcp",
|
||||
host: "::1",
|
||||
port: 6767,
|
||||
});
|
||||
expect(parseListenString("[::]:6767")).toEqual({
|
||||
type: "tcp",
|
||||
host: "::",
|
||||
port: 6767,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Windows absolute paths that are not named pipes", () => {
|
||||
// A Windows drive path like C:\daemon must NOT be silently parsed as TCP
|
||||
// (split(":") would yield host="C" and port="\\daemon" which is nonsensical).
|
||||
|
||||
@@ -66,12 +66,15 @@ export function parseListenString(listen: string): ListenTarget {
|
||||
}
|
||||
// 6. host:port — TCP
|
||||
if (listen.includes(":")) {
|
||||
const [host, portStr] = listen.split(":");
|
||||
const lastColonIdx = listen.lastIndexOf(":");
|
||||
const host = listen.slice(0, lastColonIdx);
|
||||
const portStr = listen.slice(lastColonIdx + 1);
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(parsedPort)) {
|
||||
throw new Error(`Invalid port in listen string: ${listen}`);
|
||||
}
|
||||
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
|
||||
const cleanHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
return { type: "tcp", host: cleanHost || "127.0.0.1", port: parsedPort };
|
||||
}
|
||||
throw new Error(`Invalid listen string: ${listen}`);
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ import {
|
||||
type GitHubService,
|
||||
type PullRequestTimelineItem,
|
||||
} from "../services/github-service.js";
|
||||
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||
import {
|
||||
summarizeFetchWorkspacesEntries,
|
||||
workspaceIdsOnCheckout,
|
||||
@@ -631,6 +632,7 @@ export interface SessionOptions {
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
terminalManager: TerminalManager | null;
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
providerUsageService: ProviderUsageService;
|
||||
serviceProxy?: ServiceProxySubsystem;
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
|
||||
@@ -865,6 +867,7 @@ export class Session {
|
||||
} | null = null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private readonly providerUsageService: ProviderUsageService;
|
||||
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
|
||||
private readonly serviceProxy: ServiceProxySubsystem | null;
|
||||
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
||||
@@ -934,6 +937,7 @@ export class Session {
|
||||
tts,
|
||||
terminalManager,
|
||||
providerSnapshotManager,
|
||||
providerUsageService,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
workspaceSetupSnapshots,
|
||||
@@ -1027,6 +1031,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.providerSnapshotManager = providerSnapshotManager;
|
||||
this.providerUsageService = providerUsageService;
|
||||
this.serviceProxy = serviceProxy ?? null;
|
||||
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
||||
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
|
||||
@@ -2282,6 +2287,8 @@ export class Session {
|
||||
return this.handleRefreshProvidersSnapshotRequest(msg);
|
||||
case "provider_diagnostic_request":
|
||||
return this.handleProviderDiagnosticRequest(msg);
|
||||
case "provider.usage.list.request":
|
||||
return this.handleProviderUsageListRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -4394,6 +4401,34 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProviderUsageListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "provider.usage.list.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const usage = await this.providerUsageService.listUsage();
|
||||
this.emit({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
fetchedAt: usage.fetchedAt,
|
||||
providers: usage.providers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.sessionLogger.error({ err }, "Failed to list provider usage");
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: msg.type,
|
||||
error: `Failed to list provider usage: ${err.message}`,
|
||||
code: "provider_usage_list_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
|
||||
@@ -76,6 +76,7 @@ class RecordingPushNotificationSender implements PushNotificationSender {
|
||||
function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const pushNotifications = new RecordingPushNotificationSender();
|
||||
const agentManager = {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
|
||||
@@ -125,6 +125,30 @@ function parseSentEnvelope(data: unknown): z.infer<typeof WireEnvelopeSchema> {
|
||||
return WireEnvelopeSchema.parse(JSON.parse(data));
|
||||
}
|
||||
|
||||
function sentEnvelopes(socket: MockSocket): z.infer<typeof WireEnvelopeSchema>[] {
|
||||
return socket.sent.filter((data) => typeof data === "string").map(parseSentEnvelope);
|
||||
}
|
||||
|
||||
function sentServerInfoEnvelopes(socket: MockSocket): z.infer<typeof WireEnvelopeSchema>[] {
|
||||
return sentEnvelopes(socket).filter(
|
||||
(envelope) => parseServerInfoStatusPayload(envelope.message?.payload) !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function sentBinaryFrames(socket: MockSocket): Uint8Array[] {
|
||||
return socket.sent.map(asUint8Array).filter((frame): frame is Uint8Array => frame !== null);
|
||||
}
|
||||
|
||||
function sentTerminalFrames(
|
||||
socket: MockSocket,
|
||||
): NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>[] {
|
||||
return sentBinaryFrames(socket)
|
||||
.map(decodeTerminalStreamFrame)
|
||||
.filter(
|
||||
(frame): frame is NonNullable<ReturnType<typeof decodeTerminalStreamFrame>> => frame !== null,
|
||||
);
|
||||
}
|
||||
|
||||
const BinaryFrameSchema = z.object({
|
||||
kind: z.literal("terminal"),
|
||||
frame: z.object({
|
||||
@@ -201,6 +225,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
createStub<pino.Logger>(createLogger()),
|
||||
"srv_test",
|
||||
createStub<AgentManager>({
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
@@ -705,20 +730,20 @@ describe("relay external socket reconnect behavior", () => {
|
||||
socket,
|
||||
clientId: "cid-server-info-broadcast",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(1);
|
||||
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
server.publishSpeechReadiness(speechReadiness);
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(2);
|
||||
|
||||
const secondEnvelope = parseSentEnvelope(socket.sent[1]);
|
||||
const secondEnvelope = sentServerInfoEnvelopes(socket)[1];
|
||||
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
|
||||
expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
||||
expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(true);
|
||||
|
||||
// Same readiness should not produce another server_info broadcast.
|
||||
server.publishSpeechReadiness(speechReadiness);
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(2);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
@@ -731,12 +756,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
socket,
|
||||
clientId: "cid-server-info-download-guidance",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(1);
|
||||
|
||||
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
expect(sentServerInfoEnvelopes(socket)).toHaveLength(2);
|
||||
|
||||
const envelope = parseSentEnvelope(socket.sent[1]);
|
||||
const envelope = sentServerInfoEnvelopes(socket)[1];
|
||||
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
||||
expect(payload?.capabilities?.voice?.voice.enabled).toBe(true);
|
||||
@@ -797,14 +822,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
onBinaryMessage(new Uint8Array([TerminalStreamOpcode.Output, 12, 0x6f, 0x6b]));
|
||||
}
|
||||
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
const binaryPayload = asUint8Array(socket.sent[1]);
|
||||
expect(binaryPayload).not.toBeNull();
|
||||
const frame = decodeTerminalStreamFrame(binaryPayload!);
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame!.opcode).toBe(TerminalStreamOpcode.Output);
|
||||
expect(frame!.slot).toBe(12);
|
||||
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
|
||||
const terminalFrames = sentTerminalFrames(socket);
|
||||
expect(terminalFrames).toHaveLength(1);
|
||||
const frame = terminalFrames[0];
|
||||
expect(frame.opcode).toBe(TerminalStreamOpcode.Output);
|
||||
expect(frame.slot).toBe(12);
|
||||
expect(new TextDecoder().decode(frame.payload ?? new Uint8Array())).toBe("ok");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
|
||||
const pushNotifications = new RecordingPushNotificationSender();
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
@@ -200,22 +201,40 @@ function connectClient(server: VoiceAssistantWebSocketServer) {
|
||||
return ws;
|
||||
}
|
||||
|
||||
function sentTerminalAttentionMessages(ws: ReturnType<typeof createOpenSocket>) {
|
||||
return ws.send.mock.calls
|
||||
.map(([rawMessage]) => {
|
||||
expect(typeof rawMessage).toBe("string");
|
||||
if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame");
|
||||
return JSON.parse(rawMessage);
|
||||
})
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "session" && message.message.type === "terminal_attention_required",
|
||||
)
|
||||
.map((message) => message.message.payload as TerminalAttentionPayload);
|
||||
}
|
||||
|
||||
interface TerminalAttentionPayload {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
shouldNotify: boolean;
|
||||
reason: "finished" | "needs_input";
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function readTerminalAttentionMessage(ws: ReturnType<typeof createOpenSocket>) {
|
||||
const rawMessage = ws.send.mock.calls[0]?.[0];
|
||||
expect(typeof rawMessage).toBe("string");
|
||||
if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame");
|
||||
const message = JSON.parse(rawMessage);
|
||||
expect(message.type).toBe("session");
|
||||
expect(message.message.type).toBe("terminal_attention_required");
|
||||
return message.message.payload as {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
shouldNotify: boolean;
|
||||
reason: "finished" | "needs_input";
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
const terminalMessages = sentTerminalAttentionMessages(ws);
|
||||
expect(terminalMessages).toHaveLength(1);
|
||||
const [payload] = terminalMessages;
|
||||
if (!payload) throw new Error("Expected terminal attention message");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function expectNoTerminalAttentionMessage(ws: ReturnType<typeof createOpenSocket>) {
|
||||
expect(sentTerminalAttentionMessages(ws)).toHaveLength(0);
|
||||
}
|
||||
|
||||
const CWD = "/home/user/project";
|
||||
@@ -357,7 +376,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
|
||||
|
||||
await flushAsync();
|
||||
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
expectNoTerminalAttentionMessage(ws);
|
||||
});
|
||||
|
||||
it("does not broadcast on working -> unknown", async () => {
|
||||
@@ -376,7 +395,7 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
|
||||
|
||||
await flushAsync();
|
||||
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
expectNoTerminalAttentionMessage(ws);
|
||||
});
|
||||
|
||||
it("broadcasts needs_input after working -> attention", async () => {
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
WebSocketRuntimeMetricsWindow,
|
||||
type WebSocketRuntimeCounters,
|
||||
} from "./websocket/runtime-metrics.js";
|
||||
import { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
@@ -407,6 +408,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private eventLoopDelayMonitor: ReturnType<typeof monitorEventLoopDelay> | null = null;
|
||||
private unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
private unsubscribeDaemonConfigChange: (() => void) | null = null;
|
||||
private readonly providerUsageService: ProviderUsageService;
|
||||
private unsubscribeTerminalActivity: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -533,6 +535,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
});
|
||||
});
|
||||
|
||||
this.providerUsageService = new ProviderUsageService({
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
this.wss = this.createWebSocketServer(server, wsConfig, auth);
|
||||
this.startRuntimeMetricsInterval();
|
||||
|
||||
@@ -977,6 +983,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
providerUsageService: this.providerUsageService,
|
||||
serviceProxy: this.serviceProxy ?? undefined,
|
||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
|
||||
@@ -1161,6 +1168,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
projectRemove: true,
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
worktreeRestore: true,
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
ProviderUsageFetcher,
|
||||
ProviderUsageFetcherFactoryOptions,
|
||||
ProviderUsageFetcherManifestEntry,
|
||||
} from "./provider.js";
|
||||
import { ClaudeQuotaProvider } from "./providers/claude.js";
|
||||
import { CodexQuotaProvider } from "./providers/codex.js";
|
||||
import { CopilotQuotaProvider } from "./providers/copilot.js";
|
||||
import { CursorQuotaProvider } from "./providers/cursor.js";
|
||||
import { GrokQuotaProvider } from "./providers/grok.js";
|
||||
import { KimiQuotaProvider } from "./providers/kimi.js";
|
||||
import { ZaiQuotaProvider } from "./providers/zai.js";
|
||||
|
||||
export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry[] = [
|
||||
{
|
||||
providerId: "claude",
|
||||
create: (options) =>
|
||||
new ClaudeQuotaProvider({
|
||||
logger: options.logger,
|
||||
fetch: options.fetch,
|
||||
}),
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
create: (options) =>
|
||||
new CodexQuotaProvider({
|
||||
logger: options.logger,
|
||||
fetch: options.fetch,
|
||||
}),
|
||||
},
|
||||
{
|
||||
providerId: "copilot",
|
||||
create: (options) => new CopilotQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "cursor",
|
||||
create: (options) => new CursorQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "zai",
|
||||
create: (options) => new ZaiQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "grok",
|
||||
create: (options) => new GrokQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
{
|
||||
providerId: "kimi",
|
||||
create: (options) => new KimiQuotaProvider({ logger: options.logger, fetch: options.fetch }),
|
||||
},
|
||||
];
|
||||
|
||||
export function createProviderUsageFetchers(
|
||||
options: ProviderUsageFetcherFactoryOptions,
|
||||
): ProviderUsageFetcher[] {
|
||||
return PROVIDER_USAGE_FETCHERS.map((entry) => entry.create(options));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Logger } from "pino";
|
||||
import type { ProviderUsage } from "../../server/messages.js";
|
||||
|
||||
export type ProviderApiFetch = typeof fetch;
|
||||
|
||||
export interface ProviderUsageFetcher {
|
||||
readonly providerId: string;
|
||||
readonly displayName: string;
|
||||
fetchUsage(): Promise<ProviderUsage>;
|
||||
}
|
||||
|
||||
export interface ProviderUsageFetcherFactoryOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export interface ProviderUsageFetcherManifestEntry {
|
||||
readonly providerId: string;
|
||||
create(options: ProviderUsageFetcherFactoryOptions): ProviderUsageFetcher;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageWindow,
|
||||
} from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
} from "../usage.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000;
|
||||
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
||||
const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
|
||||
const ClaudeCredentialsSchema = z.object({
|
||||
claudeAiOauth: z
|
||||
.object({
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
subscriptionType: z.string().optional(),
|
||||
rateLimitTier: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ClaudeUsageWindowSchema = z.object({
|
||||
utilization: ApiNumberSchema,
|
||||
resets_at: z.string().optional(),
|
||||
});
|
||||
|
||||
const ClaudeUsageResponseSchema = z.object({
|
||||
five_hour: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day_opus: ClaudeUsageWindowSchema.nullish(),
|
||||
seven_day_omelette: ClaudeUsageWindowSchema.nullish(),
|
||||
extra_usage: z
|
||||
.object({
|
||||
is_enabled: z.boolean().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const ClaudeTokenRefreshSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
|
||||
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
|
||||
type ClaudeTokenRefresh = z.infer<typeof ClaudeTokenRefreshSchema>;
|
||||
|
||||
interface ClaudeCredentialRecord {
|
||||
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
interface ClaudeQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
claudeHome?: string;
|
||||
claudeKeychainReader?: () => Promise<unknown | null>;
|
||||
platform?: typeof process.platform;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function buildClaudePlan(
|
||||
subscriptionType: string | undefined,
|
||||
rateLimitTier: string | undefined,
|
||||
): string | null {
|
||||
if (!subscriptionType) return null;
|
||||
const label = subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
|
||||
const tier = rateLimitTier?.split("_").pop();
|
||||
return tier ? `${label} ${tier}` : label;
|
||||
}
|
||||
|
||||
async function readClaudeKeychainCredentials(): Promise<unknown | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"security",
|
||||
["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"],
|
||||
{ timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS },
|
||||
);
|
||||
const raw = stdout.trim();
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "claude";
|
||||
readonly displayName = "Claude";
|
||||
|
||||
private readonly claudeHome: string;
|
||||
private readonly readKeychainCredentials: () => Promise<unknown | null>;
|
||||
private readonly platform: typeof process.platform;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: ClaudeQuotaProviderOptions) {
|
||||
this.claudeHome =
|
||||
options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude");
|
||||
this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials;
|
||||
this.platform = options.platform ?? process.platform;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const credentials = await this.readCredentials();
|
||||
if (!credentials) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const { oauth, filePath } = credentials;
|
||||
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
|
||||
let resp = await this.callClaudeApi(oauth.accessToken);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
if (!filePath || !oauth.refreshToken) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const refreshed = await this.refreshClaudeToken(oauth.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
await this.saveClaudeCredentials(filePath, {
|
||||
...oauth,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? oauth.refreshToken,
|
||||
});
|
||||
|
||||
resp = await this.callClaudeApi(refreshed.access_token);
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
}
|
||||
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
if (resp.five_hour) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "five_hour",
|
||||
label: "Session",
|
||||
utilizationPct: resp.five_hour.utilization,
|
||||
resetsAt: resp.five_hour.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
utilizationPct: resp.seven_day.utilization,
|
||||
resetsAt: resp.seven_day.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day_opus) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly_opus",
|
||||
label: "Weekly · Opus",
|
||||
utilizationPct: resp.seven_day_opus.utilization,
|
||||
resetsAt: resp.seven_day_opus.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (resp.seven_day_omelette) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly_omelette",
|
||||
label: "Weekly · Omelette",
|
||||
utilizationPct: resp.seven_day_omelette.utilization,
|
||||
resetsAt: resp.seven_day_omelette.resets_at ?? null,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const details: ProviderUsageDetail[] = [];
|
||||
const extraUsageEnabled = resp.extra_usage?.is_enabled;
|
||||
if (extraUsageEnabled !== undefined) {
|
||||
details.push({
|
||||
id: "extra_usage",
|
||||
label: "Extra usage",
|
||||
value: extraUsageEnabled ? "Enabled" : "Disabled",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: plan,
|
||||
windows,
|
||||
balances: [],
|
||||
details,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readCredentials(): Promise<ClaudeCredentialRecord | null> {
|
||||
const credPath = join(this.claudeHome, ".credentials.json");
|
||||
|
||||
if (existsSync(credPath)) {
|
||||
try {
|
||||
const creds = ClaudeCredentialsSchema.parse(
|
||||
JSON.parse(await fs.readFile(credPath, "utf8")),
|
||||
);
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth?.accessToken) {
|
||||
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: credPath };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the macOS Keychain below.
|
||||
}
|
||||
}
|
||||
|
||||
if (this.platform === "darwin") {
|
||||
const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
|
||||
const oauth = creds.success ? creds.data.claudeAiOauth : undefined;
|
||||
if (oauth?.accessToken) {
|
||||
return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: null };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"anthropic-beta": CLAUDE_OAUTH_BETA,
|
||||
},
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
|
||||
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
|
||||
return ClaudeUsageResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async refreshClaudeToken(refreshToken: string): Promise<ClaudeTokenRefresh | null> {
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLAUDE_CLIENT_ID,
|
||||
scope: "user:profile user:inference user:sessions:claude_code user:mcp_servers",
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return ClaudeTokenRefreshSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async saveClaudeCredentials(
|
||||
credPath: string,
|
||||
oauth: ClaudeCredentials["claudeAiOauth"],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = ClaudeCredentialsSchema.parse(
|
||||
JSON.parse(await fs.readFile(credPath, "utf8")),
|
||||
);
|
||||
existing.claudeAiOauth = oauth;
|
||||
await fs.writeFile(credPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal; Claude Code can refresh again on its own next time.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageWindow,
|
||||
} from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
} from "../usage.js";
|
||||
|
||||
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
|
||||
const CodexAuthSchema = z.object({
|
||||
tokens: z
|
||||
.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
account_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const CodexWindowSchema = z.object({
|
||||
used_percent: ApiNumberSchema.optional(),
|
||||
reset_at: ApiNumberSchema.optional(),
|
||||
});
|
||||
|
||||
const CodexUsageResponseSchema = z.object({
|
||||
plan_type: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
primary_window: CodexWindowSchema.nullish(),
|
||||
secondary_window: CodexWindowSchema.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
code_review_rate_limit: z
|
||||
.object({
|
||||
primary_window: CodexWindowSchema.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
credits: z
|
||||
.object({
|
||||
has_credits: z.boolean().optional(),
|
||||
unlimited: z.boolean().optional(),
|
||||
balance: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const CodexTokenRefreshSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type CodexAuth = z.infer<typeof CodexAuthSchema>;
|
||||
type CodexWindow = z.infer<typeof CodexWindowSchema>;
|
||||
type CodexUsageResponse = z.infer<typeof CodexUsageResponseSchema>;
|
||||
type CodexTokenRefresh = z.infer<typeof CodexTokenRefreshSchema>;
|
||||
|
||||
interface CodexAuthRecord {
|
||||
auth: CodexAuth;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CodexQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
codexHome?: string;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function codexWindow(
|
||||
window: CodexWindow | null | undefined,
|
||||
): { usedPct: number; resetsAt: string | null } | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
usedPct: window.used_percent ?? 0,
|
||||
resetsAt: window.reset_at != null ? new Date(window.reset_at * 1000).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export class CodexQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "codex";
|
||||
readonly displayName = "Codex";
|
||||
|
||||
private readonly codexHome: string;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CodexQuotaProviderOptions) {
|
||||
this.codexHome = options.codexHome || process.env["CODEX_HOME"] || join(homedir(), ".codex");
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const authRecord = await this.readCodexAuth();
|
||||
const auth = authRecord?.auth;
|
||||
const accessToken = auth?.tokens?.access_token;
|
||||
if (!authRecord || !auth || !accessToken) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const { refresh_token, account_id } = auth.tokens ?? {};
|
||||
let resp = await this.callCodexApi(accessToken, account_id);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
if (!refresh_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
const refreshed = await this.refreshCodexToken(refresh_token);
|
||||
if (!refreshed?.access_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
await this.saveCodexAuth(authRecord.path, auth, refreshed);
|
||||
resp = await this.callCodexApi(refreshed.access_token, account_id);
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
}
|
||||
|
||||
return this.toUsage(resp);
|
||||
}
|
||||
|
||||
private toUsage(resp: CodexUsageResponse): ProviderUsage {
|
||||
const session = codexWindow(resp.rate_limit?.primary_window);
|
||||
const weekly = codexWindow(resp.rate_limit?.secondary_window);
|
||||
const codeReview = codexWindow(resp.code_review_rate_limit?.primary_window);
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
|
||||
if (session) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "session",
|
||||
label: "Session",
|
||||
utilizationPct: session.usedPct,
|
||||
resetsAt: session.resetsAt,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (weekly) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
utilizationPct: weekly.usedPct,
|
||||
resetsAt: weekly.resetsAt,
|
||||
tone: weekly.usedPct >= 70 ? "warning" : "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (codeReview) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "code_review",
|
||||
label: "Code review",
|
||||
utilizationPct: codeReview.usedPct,
|
||||
resetsAt: codeReview.resetsAt,
|
||||
tone: codeReview.usedPct >= 70 ? "warning" : "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (resp.credits?.balance !== undefined) {
|
||||
balances.push({
|
||||
id: "credits",
|
||||
label: "Credits",
|
||||
remaining: resp.credits.balance,
|
||||
unit: "usd",
|
||||
tone: balanceToneFromRemaining(resp.credits.balance),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: resp.plan_type ?? null,
|
||||
windows,
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readCodexAuth(): Promise<CodexAuthRecord | null> {
|
||||
const candidates = [
|
||||
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
|
||||
join(homedir(), ".config", "codex", "auth.json"),
|
||||
join(this.codexHome, "auth.json"),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
if (auth.tokens?.access_token) return { auth, path };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async callCodexApi(
|
||||
token: string,
|
||||
accountId?: string,
|
||||
): Promise<CodexUsageResponse | "NEEDS_AUTH"> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
};
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
|
||||
if (!res.ok) throw new Error(`Codex usage API returned ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (text.trim().startsWith("<")) return "NEEDS_AUTH";
|
||||
return CodexUsageResponseSchema.parse(JSON.parse(text));
|
||||
}
|
||||
|
||||
private async refreshCodexToken(refreshToken: string): Promise<CodexTokenRefresh | null> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return CodexTokenRefreshSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async saveCodexAuth(
|
||||
authPath: string,
|
||||
original: CodexAuth,
|
||||
refreshed: CodexTokenRefresh,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const updated: CodexAuth = {
|
||||
...original,
|
||||
tokens: {
|
||||
...original.tokens,
|
||||
access_token: refreshed.access_token ?? original.tokens?.access_token,
|
||||
refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal; the next call can refresh again.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const CopilotUsageResponseSchema = z.object({
|
||||
copilot_plan: ApiOptionalStringSchema,
|
||||
quota_reset_date: ApiOptionalStringSchema,
|
||||
});
|
||||
|
||||
interface CopilotQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
async function readGithubCliToken(): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml"));
|
||||
}
|
||||
candidates.push(join(homedir(), ".config", "gh", "hosts.yml"));
|
||||
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path, "utf8");
|
||||
const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/);
|
||||
if (match?.[1]) return match[1];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class CopilotQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "copilot";
|
||||
readonly displayName = "GitHub Copilot";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CopilotQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["COPILOT_TOKEN"] ||
|
||||
process.env["GITHUB_TOKEN"] ||
|
||||
process.env["GITHUB_PAT"] ||
|
||||
(await readGithubCliToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"Editor-Version": "vscode/1.96.2",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"X-Github-Api-Version": "2025-04-01",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Copilot usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = CopilotUsageResponseSchema.parse(await res.json());
|
||||
const details: ProviderUsageDetail[] = resp.quota_reset_date
|
||||
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: resp.copilot_plan || null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNullableNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
toIsoStringOrNull,
|
||||
unavailableUsage,
|
||||
} from "../usage.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
|
||||
|
||||
const CursorBillingCycleTimestampSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
|
||||
z.union([z.string(), z.number()]).nullable(),
|
||||
);
|
||||
|
||||
const CursorUsageResponseSchema = z.object({
|
||||
planUsage: z
|
||||
.object({
|
||||
totalSpend: ApiNullableNumberSchema,
|
||||
includedSpend: ApiNullableNumberSchema,
|
||||
bonusSpend: ApiNullableNumberSchema,
|
||||
remaining: ApiNullableNumberSchema,
|
||||
limit: ApiNullableNumberSchema,
|
||||
})
|
||||
.nullish(),
|
||||
billingCycleStart: CursorBillingCycleTimestampSchema,
|
||||
billingCycleEnd: CursorBillingCycleTimestampSchema,
|
||||
});
|
||||
|
||||
const CursorAuthStatusSchema = z.object({
|
||||
accessToken: z.string().optional(),
|
||||
});
|
||||
|
||||
type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
|
||||
|
||||
interface CursorQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function parseCursorBillingCycleTimestamp(
|
||||
value: CursorUsageResponse["billingCycleStart"],
|
||||
): string | null {
|
||||
if (value === null) return null;
|
||||
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const numeric = Number(raw);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const timestampMs = Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric;
|
||||
return toIsoStringOrNull(timestampMs);
|
||||
}
|
||||
|
||||
return toIsoStringOrNull(new Date(raw).getTime());
|
||||
}
|
||||
|
||||
function centsToDollars(value: number | null): number | null {
|
||||
return value === null ? null : value / 100;
|
||||
}
|
||||
|
||||
async function readCursorTokenFromSqlite(): Promise<string | null> {
|
||||
const dbPaths: string[] = [];
|
||||
if (process.env["APPDATA"]) {
|
||||
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
}
|
||||
dbPaths.push(
|
||||
join(
|
||||
homedir(),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Cursor",
|
||||
"User",
|
||||
"globalStorage",
|
||||
"state.vscdb",
|
||||
),
|
||||
);
|
||||
dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
|
||||
|
||||
for (const path of dbPaths) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"sqlite3",
|
||||
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
|
||||
{ timeout: CURSOR_SQLITE_TIMEOUT_MS },
|
||||
);
|
||||
if (stdout) {
|
||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
|
||||
if (parsed.accessToken) return parsed.accessToken;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class CursorQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "cursor";
|
||||
readonly displayName = "Cursor";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CursorQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["CURSOR_ACCESS_TOKEN"] ||
|
||||
process.env["CURSOR_TOKEN"] ||
|
||||
(await readCursorTokenFromSqlite());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Connect-Protocol-Version": "1",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Cursor usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = CursorUsageResponseSchema.parse(await res.json());
|
||||
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (resp.planUsage) {
|
||||
const totalSpend = centsToDollars(resp.planUsage.totalSpend);
|
||||
const remaining = centsToDollars(resp.planUsage.remaining);
|
||||
const limit = centsToDollars(resp.planUsage.limit);
|
||||
balances.push({
|
||||
id: "plan_usage",
|
||||
label: "Plan usage",
|
||||
used: totalSpend,
|
||||
remaining,
|
||||
limit,
|
||||
unit: "usd",
|
||||
resetsAt: billingCycleEnd,
|
||||
tone: balanceToneFromRemaining(remaining),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
} from "../usage.js";
|
||||
|
||||
const GrokUsageResponseSchema = z.object({
|
||||
config: z
|
||||
.object({
|
||||
monthlyLimit: z
|
||||
.object({
|
||||
val: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
usage: z
|
||||
.object({
|
||||
creditUsage: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const GrokAuthSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GrokQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "grok";
|
||||
readonly displayName = "Grok";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: GrokQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Grok usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||
const monthlyLimit = resp.config?.monthlyLimit?.val ?? null;
|
||||
const creditUsage = resp.usage?.creditUsage ?? null;
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (monthlyLimit !== null || creditUsage !== null) {
|
||||
const remaining =
|
||||
monthlyLimit !== null && creditUsage !== null
|
||||
? Math.max(0, monthlyLimit - creditUsage)
|
||||
: null;
|
||||
balances.push({
|
||||
id: "monthly_credits",
|
||||
label: "Monthly credits",
|
||||
used: creditUsage,
|
||||
remaining,
|
||||
limit: monthlyLimit,
|
||||
unit: "credits",
|
||||
tone: balanceToneFromRemaining(remaining),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readGrokToken(): Promise<string | null> {
|
||||
const path = join(homedir(), ".grok", "auth.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
return auth.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const KimiUsageResponseSchema = z.object({
|
||||
usage: z
|
||||
.object({
|
||||
limit: ApiOptionalStringSchema,
|
||||
remaining: ApiOptionalStringSchema,
|
||||
resetTime: ApiOptionalStringSchema,
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const KimiAuthSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
});
|
||||
|
||||
interface KimiQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class KimiQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "kimi";
|
||||
readonly displayName = "Kimi";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: KimiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"] || (await this.readKimiToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.kimi.com/coding/v1/usages", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Kimi usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = KimiUsageResponseSchema.parse(await res.json());
|
||||
const limit = resp.usage?.limit === undefined ? null : Number(resp.usage.limit);
|
||||
const remaining = resp.usage?.remaining === undefined ? null : Number(resp.usage.remaining);
|
||||
const hasFiniteLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
|
||||
const hasFiniteRemaining = typeof remaining === "number" && Number.isFinite(remaining);
|
||||
const usedPct =
|
||||
hasFiniteLimit && hasFiniteRemaining
|
||||
? Math.max(0, Math.min(100, ((limit - remaining) / limit) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [
|
||||
{
|
||||
id: "coding_usage",
|
||||
label: "Coding usage",
|
||||
usedPct,
|
||||
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
||||
resetsAt: resp.usage?.resetTime ?? null,
|
||||
tone: "ok",
|
||||
},
|
||||
],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readKimiToken(): Promise<string | null> {
|
||||
const path = join(homedir(), ".kimi", "credentials", "kimi-code.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
return credentials.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const ZaiUsageResponseSchema = z.object({
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
productName: ApiOptionalStringSchema,
|
||||
status: ApiOptionalStringSchema,
|
||||
purchaseTime: ApiOptionalStringSchema,
|
||||
valid: ApiOptionalStringSchema,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface ZaiQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class ZaiQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "zai";
|
||||
readonly displayName = "Z.ai";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: ZaiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"];
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://api.z.ai/api/biz/subscription/list",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Z.ai usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = ZaiUsageResponseSchema.parse(await res.json());
|
||||
const sub = resp.data?.[0];
|
||||
if (!sub) return unavailableUsage(this);
|
||||
|
||||
const details: ProviderUsageDetail[] = [];
|
||||
if (sub.status) details.push({ id: "status", label: "Status", value: sub.status });
|
||||
if (sub.valid) details.push({ id: "valid", label: "Valid", value: sub.valid });
|
||||
if (sub.purchaseTime) {
|
||||
details.push({ id: "purchase_time", label: "Purchased", value: sub.purchaseTime });
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: sub.productName || null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderUsage } from "../../server/messages.js";
|
||||
import type { ProviderUsageFetcher } from "./provider.js";
|
||||
import { ClaudeQuotaProvider } from "./providers/claude.js";
|
||||
import { CodexQuotaProvider } from "./providers/codex.js";
|
||||
import { CopilotQuotaProvider } from "./providers/copilot.js";
|
||||
import { CursorQuotaProvider } from "./providers/cursor.js";
|
||||
import { GrokQuotaProvider } from "./providers/grok.js";
|
||||
import { KimiQuotaProvider } from "./providers/kimi.js";
|
||||
import { ZaiQuotaProvider } from "./providers/zai.js";
|
||||
import { ProviderUsageService } from "./service.js";
|
||||
|
||||
function writeClaudeCredentials(
|
||||
dir: string,
|
||||
accessToken: string,
|
||||
refreshToken = "rt_test",
|
||||
subscriptionType = "pro",
|
||||
rateLimitTier = "default_1x",
|
||||
): void {
|
||||
writeFileSync(
|
||||
join(dir, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken, refreshToken, subscriptionType, rateLimitTier },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_codex"): void {
|
||||
writeFileSync(
|
||||
join(dir, "auth.json"),
|
||||
JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken } }),
|
||||
);
|
||||
}
|
||||
|
||||
function makeClaudeResponse(
|
||||
overrides: Partial<{
|
||||
five_hour: { utilization: number | string; resets_at: string };
|
||||
seven_day: { utilization: number | string; resets_at: string };
|
||||
seven_day_opus: { utilization: number | string; resets_at: string };
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
five_hour: { utilization: 11, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" },
|
||||
seven_day_opus: { utilization: 0.5, resets_at: "2026-06-04T00:00:00Z" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCodexResponse(overrides: object = {}) {
|
||||
return {
|
||||
plan_type: "plus",
|
||||
email: "user@example.com",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 42, reset_at: 1_748_812_800 },
|
||||
secondary_window: { used_percent: 8, reset_at: 1_749_072_000 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
|
||||
return vi.fn(async (url: RequestInfo | URL) => {
|
||||
const key = url.toString();
|
||||
const handler = handlers.get(key);
|
||||
if (!handler) throw new Error(`Unmocked fetch: ${key}`);
|
||||
return handler();
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: () => logger,
|
||||
};
|
||||
return logger as never;
|
||||
}
|
||||
|
||||
function usageFetcher(usage: ProviderUsage): ProviderUsageFetcher {
|
||||
return {
|
||||
providerId: usage.providerId,
|
||||
displayName: usage.displayName,
|
||||
fetchUsage: async () => usage,
|
||||
};
|
||||
}
|
||||
|
||||
function findProvider(result: { providers: ProviderUsage[] }, providerId: string): ProviderUsage {
|
||||
const provider = result.providers.find((candidate) => candidate.providerId === providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`Missing provider ${providerId}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("ProviderUsageService", () => {
|
||||
it("returns arbitrary registered providers and windows as normalized usage data", async () => {
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
usageFetcher({
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
resetsAt: "2026-07-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.listUsage()).resolves.toEqual({
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
resetsAt: "2026-07-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("caches usage until forced to refresh", async () => {
|
||||
let now = Date.parse("2026-06-19T00:00:00.000Z");
|
||||
let calls = 0;
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => now,
|
||||
cacheTtlMs: 60_000,
|
||||
fetchers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
fetchUsage: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: calls }],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await service.listUsage();
|
||||
now += 30_000;
|
||||
const cached = await service.listUsage();
|
||||
const refreshed = await service.listUsage({ forceRefresh: true });
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(cached).toBe(first);
|
||||
expect(refreshed.providers[0]?.windows[0]?.usedPct).toBe(2);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent cache misses", async () => {
|
||||
let calls = 0;
|
||||
let resolveUsage: ((usage: ProviderUsage) => void) | null = null;
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
fetchUsage: () => {
|
||||
calls += 1;
|
||||
return new Promise<ProviderUsage>((resolve) => {
|
||||
resolveUsage = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = service.listUsage();
|
||||
const second = service.listUsage();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
resolveUsage?.({
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 12 }],
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBe(secondResult);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("isolates one provider error without dropping other providers", async () => {
|
||||
const service = new ProviderUsageService({
|
||||
logger: createLogger(),
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
fetchUsage: async () => {
|
||||
throw new Error("Claude auth expired");
|
||||
},
|
||||
},
|
||||
usageFetcher({
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.listUsage()).resolves.toEqual({
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("real provider usage fetchers", () => {
|
||||
let claudeHome: string;
|
||||
let codexHome: string;
|
||||
let homeDir: string;
|
||||
let fetchApi: typeof fetch;
|
||||
let originalEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
claudeHome = mkdtempSync(join(tmpdir(), "usage-test-claude-"));
|
||||
codexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-"));
|
||||
homeDir = mkdtempSync(join(tmpdir(), "usage-test-home-"));
|
||||
fetchApi = mockFetch(new Map());
|
||||
originalEnv = { ...process.env };
|
||||
process.env["HOME"] = homeDir;
|
||||
|
||||
for (const key of [
|
||||
"APPDATA",
|
||||
"COPILOT_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_PAT",
|
||||
"CURSOR_ACCESS_TOKEN",
|
||||
"CURSOR_TOKEN",
|
||||
"ZAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"GROK_API_KEY",
|
||||
"GROK_TOKEN",
|
||||
"KIMI_TOKEN",
|
||||
"KIMI_API_KEY",
|
||||
"CODEX_HOME",
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
for (const key in originalEnv) {
|
||||
process.env[key] = originalEnv[key];
|
||||
}
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function service(
|
||||
options: { platform?: typeof process.platform; keychain?: () => Promise<unknown | null> } = {},
|
||||
) {
|
||||
const logger = createLogger();
|
||||
const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) =>
|
||||
fetchApi(url, init)) as typeof fetch;
|
||||
return new ProviderUsageService({
|
||||
logger,
|
||||
now: () => Date.parse("2026-06-19T00:00:00.000Z"),
|
||||
fetchers: [
|
||||
new ClaudeQuotaProvider({
|
||||
logger,
|
||||
claudeHome,
|
||||
claudeKeychainReader: options.keychain ?? (async () => null),
|
||||
platform: options.platform,
|
||||
fetch: fetchThroughTestDouble,
|
||||
}),
|
||||
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
|
||||
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
],
|
||||
cacheTtlMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
() =>
|
||||
jsonResponse(
|
||||
makeClaudeResponse({
|
||||
five_hour: { utilization: "11", resets_at: "2026-06-01T21:00:00Z" },
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
const claude = findProvider(result, "claude");
|
||||
|
||||
expect(claude).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "Pro 1x",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "five_hour", usedPct: 11 }),
|
||||
expect.objectContaining({ id: "weekly", usedPct: 1 }),
|
||||
expect.objectContaining({ id: "weekly_opus", usedPct: 0.5 }),
|
||||
]),
|
||||
});
|
||||
expect(fetchApi).toHaveBeenCalledWith(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unavailable Claude usage when credentials are missing", async () => {
|
||||
fetchApi = vi.fn() as never;
|
||||
|
||||
const result = await service().listUsage();
|
||||
const claude = findProvider(result, "claude");
|
||||
|
||||
expect(claude.status).toBe("unavailable");
|
||||
expect(fetchApi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes Claude access tokens on 401 and retries", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_expired", "rt_valid");
|
||||
let usageCalls = 0;
|
||||
fetchApi = vi.fn(async (url: RequestInfo | URL) => {
|
||||
const endpoint = url.toString();
|
||||
if (endpoint === "https://api.anthropic.com/api/oauth/usage") {
|
||||
usageCalls += 1;
|
||||
if (usageCalls === 1) return new Response(null, { status: 401 });
|
||||
return jsonResponse(makeClaudeResponse());
|
||||
}
|
||||
if (endpoint === "https://platform.claude.com/v1/oauth/token") {
|
||||
return jsonResponse({ access_token: "at_refreshed", refresh_token: "rt_new" });
|
||||
}
|
||||
throw new Error(`Unmocked: ${endpoint}`);
|
||||
}) as never;
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("available");
|
||||
expect(usageCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("returns unavailable Claude usage when 401 persists after refresh", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_bad", "rt_bad");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
["https://api.anthropic.com/api/oauth/usage", () => new Response(null, { status: 401 })],
|
||||
[
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
() => jsonResponse({ access_token: "at_still_bad", refresh_token: "rt_still_bad" }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("does not refresh Claude tokens read from the macOS Keychain", async () => {
|
||||
const usageFetch = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
fetchApi = usageFetch as never;
|
||||
|
||||
const result = await service({
|
||||
platform: "darwin",
|
||||
keychain: async () => ({
|
||||
claudeAiOauth: {
|
||||
accessToken: "at_expired",
|
||||
refreshToken: "rt_valid",
|
||||
},
|
||||
}),
|
||||
}).listUsage();
|
||||
|
||||
expect(findProvider(result, "claude").status).toBe("unavailable");
|
||||
expect(usageFetch).toHaveBeenCalledTimes(1);
|
||||
expect(usageFetch).toHaveBeenCalledWith(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer at_expired" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches Codex windows and coerces string credit balances", async () => {
|
||||
writeCodexAuth(codexHome, "at_codex_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
() =>
|
||||
jsonResponse(
|
||||
makeCodexResponse({
|
||||
code_review_rate_limit: null,
|
||||
credits: { balance: "0" },
|
||||
}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
const codex = findProvider(result, "codex");
|
||||
|
||||
expect(codex).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "plus",
|
||||
windows: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "session", usedPct: 42 }),
|
||||
expect.objectContaining({ id: "weekly", usedPct: 8 }),
|
||||
]),
|
||||
balances: [expect.objectContaining({ id: "credits", remaining: 0 })],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a Codex HTML usage response as auth failure", async () => {
|
||||
writeCodexAuth(codexHome, "at_codex_stale");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
() => new Response("<html>Login</html>", { status: 200 }),
|
||||
],
|
||||
["https://auth.openai.com/oauth/token", () => new Response(null, { status: 401 })],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service().listUsage();
|
||||
|
||||
expect(findProvider(result, "codex").status).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("persists refreshed Codex tokens to the auth file that was read", async () => {
|
||||
const alternateCodexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-alt-"));
|
||||
process.env["CODEX_HOME"] = alternateCodexHome;
|
||||
writeFileSync(join(alternateCodexHome, "auth.json"), JSON.stringify({ tokens: {} }));
|
||||
writeCodexAuth(codexHome, "at_codex_stale", "rt_codex_valid");
|
||||
|
||||
let usageCalls = 0;
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
() => {
|
||||
usageCalls += 1;
|
||||
if (usageCalls === 1) return new Response(null, { status: 401 });
|
||||
return jsonResponse(makeCodexResponse());
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://auth.openai.com/oauth/token",
|
||||
() => jsonResponse({ access_token: "at_codex_fresh", refresh_token: "rt_codex_fresh" }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await service().listUsage();
|
||||
|
||||
const refreshedAuth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8"));
|
||||
const untouchedAuth = JSON.parse(readFileSync(join(alternateCodexHome, "auth.json"), "utf8"));
|
||||
expect(findProvider(result, "codex").status).toBe("available");
|
||||
expect(refreshedAuth.tokens.access_token).toBe("at_codex_fresh");
|
||||
expect(refreshedAuth.tokens.refresh_token).toBe("rt_codex_fresh");
|
||||
expect(untouchedAuth.tokens.access_token).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(alternateCodexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fetches Copilot usage from COPILOT_TOKEN", async () => {
|
||||
process.env["COPILOT_TOKEN"] = "copilot_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
() =>
|
||||
jsonResponse({
|
||||
copilot_plan: "business",
|
||||
quota_reset_date: "2026-07-01T00:00:00Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const copilot = findProvider(await service().listUsage(), "copilot");
|
||||
|
||||
expect(copilot).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "business",
|
||||
details: [{ id: "reset", label: "Quota reset", value: "2026-07-01T00:00:00Z" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Cursor usage and normalizes malformed billing dates to null", async () => {
|
||||
process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
() =>
|
||||
jsonResponse({
|
||||
planUsage: {
|
||||
totalSpend: "1500",
|
||||
includedSpend: "1000",
|
||||
bonusSpend: "500",
|
||||
remaining: "2500",
|
||||
limit: "4000",
|
||||
},
|
||||
billingCycleStart: "2026-01-14T12:42:14.000Z",
|
||||
billingCycleEnd: "not-a-date",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const cursor = findProvider(await service().listUsage(), "cursor");
|
||||
|
||||
expect(cursor).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "plan_usage",
|
||||
used: 15,
|
||||
remaining: 25,
|
||||
limit: 40,
|
||||
resetsAt: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
|
||||
process.env["ZAI_API_KEY"] = "zai_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.z.ai/api/biz/subscription/list",
|
||||
() =>
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
productName: "GLM Coding Max",
|
||||
status: "VALID",
|
||||
purchaseTime: "2026-01-12 16:55:13",
|
||||
valid: "2026-02-12 16:55:13-2026-03-12 16:55:13",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(zai).toMatchObject({
|
||||
status: "available",
|
||||
planLabel: "GLM Coding Max",
|
||||
details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]),
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Grok usage and preserves zero values", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: { monthlyLimit: { val: 0 } },
|
||||
usage: { creditUsage: 0 },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 0,
|
||||
remaining: 0,
|
||||
limit: 0,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Kimi usage from KIMI_TOKEN", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.kimi.com/coding/v1/usages",
|
||||
() =>
|
||||
jsonResponse({
|
||||
usage: {
|
||||
limit: "100",
|
||||
remaining: "74",
|
||||
resetTime: "2026-02-11T17:32:50Z",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const kimi = findProvider(await service().listUsage(), "kimi");
|
||||
|
||||
expect(kimi).toMatchObject({
|
||||
status: "available",
|
||||
windows: [
|
||||
expect.objectContaining({
|
||||
id: "coding_usage",
|
||||
usedPct: 26,
|
||||
remainingPct: 74,
|
||||
resetsAt: "2026-02-11T17:32:50Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Logger } from "pino";
|
||||
import type { ProviderUsage } from "../../server/messages.js";
|
||||
import { createProviderUsageFetchers } from "./manifest.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "./provider.js";
|
||||
import { unavailableUsage } from "./usage.js";
|
||||
|
||||
export interface ProviderUsageServiceOptions {
|
||||
logger: Logger;
|
||||
fetchers?: ProviderUsageFetcher[];
|
||||
fetch?: ProviderApiFetch;
|
||||
cacheTtlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface ProviderUsageListResult {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class ProviderUsageService {
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchers: ProviderUsageFetcher[];
|
||||
private readonly cacheTtlMs: number;
|
||||
private readonly now: () => number;
|
||||
private cached: { fetchedAtMs: number; result: ProviderUsageListResult } | null = null;
|
||||
private inFlight: Promise<ProviderUsageListResult> | null = null;
|
||||
|
||||
constructor(options: ProviderUsageServiceOptions) {
|
||||
this.logger = options.logger.child({ module: "provider-usage-service" });
|
||||
this.fetchers =
|
||||
options.fetchers ??
|
||||
createProviderUsageFetchers({
|
||||
logger: this.logger,
|
||||
fetch: options.fetch,
|
||||
});
|
||||
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async listUsage(options?: { forceRefresh?: boolean }): Promise<ProviderUsageListResult> {
|
||||
const nowMs = this.now();
|
||||
if (
|
||||
!options?.forceRefresh &&
|
||||
this.cached &&
|
||||
nowMs - this.cached.fetchedAtMs < this.cacheTtlMs
|
||||
) {
|
||||
return this.cached.result;
|
||||
}
|
||||
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
|
||||
const request = this.fetchFreshUsage(nowMs);
|
||||
this.inFlight = request;
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (this.inFlight === request) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFreshUsage(nowMs: number): Promise<ProviderUsageListResult> {
|
||||
const settled = await Promise.allSettled(this.fetchers.map((fetcher) => fetcher.fetchUsage()));
|
||||
const providers = settled.map((result, index) => {
|
||||
const fetcher = this.fetchers[index];
|
||||
if (result.status === "fulfilled") {
|
||||
return result.value;
|
||||
}
|
||||
this.logger.debug(
|
||||
{ err: result.reason, providerId: fetcher.providerId },
|
||||
"Provider usage fetch failed",
|
||||
);
|
||||
return unavailableUsage({
|
||||
providerId: fetcher.providerId,
|
||||
displayName: fetcher.displayName,
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
});
|
||||
});
|
||||
|
||||
const result = { fetchedAt: new Date(nowMs).toISOString(), providers };
|
||||
this.cached = { fetchedAtMs: nowMs, result };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageWindow,
|
||||
} from "../../server/messages.js";
|
||||
import type { ProviderApiFetch } from "./provider.js";
|
||||
|
||||
const PROVIDER_HTTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const ApiNumberSchema = z.coerce.number().finite();
|
||||
export const ApiNullableNumberSchema = z.preprocess(
|
||||
(value) => (value == null ? null : value),
|
||||
ApiNumberSchema.nullable(),
|
||||
);
|
||||
export const ApiOptionalStringSchema = z.preprocess(
|
||||
(value) => (value == null ? undefined : value),
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
export function fetchProviderApi(
|
||||
fetchApi: ProviderApiFetch,
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetchApi(input, {
|
||||
...init,
|
||||
signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
export function unavailableUsage(provider: {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
error?: string | null;
|
||||
}): ProviderUsage {
|
||||
return {
|
||||
providerId: provider.providerId,
|
||||
displayName: provider.displayName,
|
||||
status: provider.error ? "error" : "unavailable",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: provider.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function windowFromUsedPct(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
utilizationPct: number | null | undefined;
|
||||
resetsAt?: string | null;
|
||||
tone?: ProviderUsageWindow["tone"];
|
||||
}): ProviderUsageWindow {
|
||||
const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null;
|
||||
const window: ProviderUsageWindow = {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
usedPct,
|
||||
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
||||
resetsAt: input.resetsAt ?? null,
|
||||
};
|
||||
if (input.tone) {
|
||||
window.tone = input.tone;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
export function balanceToneFromRemaining(
|
||||
remaining: number | null | undefined,
|
||||
): ProviderUsageBalance["tone"] {
|
||||
if (typeof remaining !== "number") return "default";
|
||||
if (remaining <= 0) return "danger";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export function toIsoStringOrNull(timestampMs: number): string | null {
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
+4
-3
@@ -38,7 +38,7 @@ Write-Host @"
|
||||
======================================================
|
||||
Home: $($env:PASEO_HOME)
|
||||
Models: $($env:PASEO_LOCAL_MODELS_DIR)
|
||||
Daemon: localhost:6767
|
||||
Daemon: localhost:6768
|
||||
======================================================
|
||||
"@
|
||||
|
||||
@@ -49,12 +49,13 @@ $env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
# Configure the app to auto-connect to this daemon on localhost
|
||||
$env:APP_VARIANT = "development"
|
||||
$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767"
|
||||
$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6768"
|
||||
$env:PASEO_LISTEN = "127.0.0.1:6768"
|
||||
$env:BROWSER = "none"
|
||||
|
||||
# Run both with concurrently
|
||||
concurrently `
|
||||
--names "daemon,metro" `
|
||||
--prefix-colors "cyan,magenta" `
|
||||
"npm run dev:server" `
|
||||
"npm run dev:server:watch" `
|
||||
"cd packages/app && npx expo start"
|
||||
|
||||
Reference in New Issue
Block a user