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:
Aditya Borakati
2026-06-20 12:19:50 +08:00
committed by GitHub
co-authored by Claude Opus 4.8 ABorakati Mohamed Boudra lumingjun
parent 2b0740ff84
commit acea7f3d24
59 changed files with 3944 additions and 132 deletions
+15
View File
@@ -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
+25 -51
View File
@@ -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"
}
+154
View File
@@ -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 });
});
},
};
}
+2 -1
View File
@@ -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();
}
+16 -1
View File
@@ -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),
+1
View File
@@ -1394,6 +1394,7 @@ export const ar: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
host: "Host",
},
+1
View File
@@ -1401,6 +1401,7 @@ export const en = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
host: "Host",
},
+1
View File
@@ -1430,6 +1430,7 @@ export const es: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
host: "Host",
},
+1
View File
@@ -1433,6 +1433,7 @@ export const fr: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
host: "Host",
},
+1
View File
@@ -1422,6 +1422,7 @@ export const ru: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
host: "Host",
},
+1
View File
@@ -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,
},
}));
+201
View File
@@ -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,
},
}));
+18
View File
@@ -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;
+53
View File
@@ -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();
}
}
+26
View File
@@ -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];
+8
View File
@@ -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,
},
}));
+26
View File
@@ -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");
});
+1
View File
@@ -417,6 +417,7 @@ export const HOST_SECTION_SLUGS = [
"agents",
"workspaces",
"providers",
"usage",
"terminals",
"host",
] as const;
+75
View File
@@ -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();
+12
View File
@@ -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(
+19 -9
View File
@@ -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`""
+87
View File
@@ -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({
+73
View File
@@ -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).
+5 -2
View File
@@ -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}`);
}
+35
View File
@@ -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
View File
@@ -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"