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
@@ -0,0 +1,154 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ProviderUsage } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
interface ProviderUsageFixturePayload {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
export interface ProviderUsageFixture {
|
||||
requestCount(): number;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withProviderUsageFeature(message: WebSocketMessage): string | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const payload = maybeEnvelope.message?.payload;
|
||||
if (
|
||||
maybeEnvelope.type !== "session" ||
|
||||
maybeEnvelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
...maybeEnvelope,
|
||||
message: {
|
||||
...maybeEnvelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...(typeof payload.features === "object" && payload.features !== null
|
||||
? payload.features
|
||||
: {}),
|
||||
providerUsageList: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installProviderUsageFixture(
|
||||
page: Page,
|
||||
payloads: ProviderUsageFixturePayload[],
|
||||
): Promise<ProviderUsageFixture> {
|
||||
let requests = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
function notifyWaiters() {
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (requests >= waiter.count) {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiters.push(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function payloadForRequest(): ProviderUsageFixturePayload {
|
||||
const index = Math.min(requests - 1, payloads.length - 1);
|
||||
const payload = payloads[index];
|
||||
if (!payload) {
|
||||
throw new Error("Provider usage fixture requires at least one payload.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "provider.usage.list.request") {
|
||||
requests += 1;
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId !== "string") {
|
||||
throw new Error("provider.usage.list.request missing requestId");
|
||||
}
|
||||
const payload = payloadForRequest();
|
||||
notifyWaiters();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
providers: payload.providers,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
requestCount() {
|
||||
return requests;
|
||||
},
|
||||
waitForRequestCount(count: number) {
|
||||
if (requests >= count) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
waiters.push({ count, resolve });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const SECTION_LABELS = {
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -374,6 +374,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-usage")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
|
||||
Reference in New Issue
Block a user