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