feat(client): expose provider usage (#4062)

* feat(client): expose provider usage

* fix(client): gate provider usage by capability
This commit is contained in:
Ivan Brykov
2026-09-07 19:52:34 +02:00
committed by GitHub
parent c46aee4e6a
commit c43df5d4c3
3 changed files with 91 additions and 1 deletions
+72 -1
View File
@@ -83,7 +83,10 @@ function parseSentFrame(
}
async function connectClient(
features: Record<string, boolean> = { providersSnapshotCwd: true },
features: Record<string, boolean> = {
providerUsageList: true,
providersSnapshotCwd: true,
},
): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
vi.stubGlobal("WebSocket", FakeWebSocket);
const client = createPaseoClient({
@@ -1257,6 +1260,62 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
provider: "codex",
diagnostic: "Codex is ready.",
});
const usagePromise = client.providers.listUsage({
requestId: "provider-usage-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "provider.usage.list.request",
requestId: "provider-usage-request",
});
ws.message(
sessionMessage({
type: "provider.usage.list.response",
payload: {
requestId: "provider-usage-request",
fetchedAt: "2026-05-16T00:10:00.000Z",
providers: [
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "pro",
windows: [
{
id: "weekly",
label: "Weekly limit",
usedPct: 25,
remainingPct: 75,
resetsAt: "2026-05-20T00:00:00.000Z",
tone: "ok",
},
],
},
],
},
}),
);
await expect(usagePromise).resolves.toEqual({
requestId: "provider-usage-request",
fetchedAt: "2026-05-16T00:10:00.000Z",
providers: [
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "pro",
windows: [
{
id: "weekly",
label: "Weekly limit",
usedPct: 25,
remainingPct: 75,
resetsAt: "2026-05-20T00:00:00.000Z",
tone: "ok",
},
],
},
],
});
const snapshotUpdates: string[] = [];
const snapshotModelDefaults: Array<string | undefined> = [];
@@ -1307,6 +1366,18 @@ test("waitForReady requires canonical provider snapshot identity from the host",
await client.close();
});
test("provider usage requires the advertised host capability", async () => {
const { client, ws } = await connectClient({});
const sentBeforeUsage = ws.sent.length;
await expect(client.providers.listUsage()).rejects.toThrow(
"Update the host to list provider usage.",
);
expect(ws.sent).toHaveLength(sentBeforeUsage);
await client.close();
});
test("config actions delegate to existing daemon config RPCs", async () => {
const { client, ws } = await connectClient();
+18
View File
@@ -16,6 +16,7 @@ import type {
MutableDaemonConfig,
MutableDaemonConfigPatch,
ProviderDiagnosticResponseMessage,
ProviderUsageListResponseMessage,
ProjectPlacementPayload,
WorkspaceProjectDescriptorPayload,
RefreshProvidersSnapshotResponseMessage,
@@ -369,6 +370,10 @@ export type PaseoProviderSnapshotUpdate = Extract<
>["payload"];
export type PaseoProviderRefreshResult = RefreshProvidersSnapshotResponseMessage["payload"];
export type PaseoProviderDiagnosticResult = ProviderDiagnosticResponseMessage["payload"];
export type PaseoProviderUsageResult = ProviderUsageListResponseMessage["payload"];
export interface PaseoProviderUsageOptions {
requestId?: string;
}
export interface PaseoProviderListOptions {
cwd?: string;
@@ -407,6 +412,7 @@ export interface PaseoProviderActions {
provider: PaseoAgentProvider,
options?: { requestId?: string },
): Promise<PaseoProviderDiagnosticResult>;
listUsage(options?: PaseoProviderUsageOptions): Promise<PaseoProviderUsageResult>;
subscribe(handler: (update: PaseoProviderSnapshotUpdate) => void): () => void;
}
@@ -545,6 +551,7 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi {
waitForReady: (options) => waitForProvidersReady(daemonClient, options),
refresh: (options) => daemonClient.refreshProvidersSnapshot(options),
diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options),
listUsage: (options) => listProviderUsage(daemonClient, options),
subscribe: (handler) =>
daemonClient.on("providers_snapshot_update", (message) => {
handler(message.payload);
@@ -804,6 +811,17 @@ function parseProviderModel(selection: string): { provider: string; model: strin
};
}
function listProviderUsage(
daemonClient: DaemonClient,
options?: PaseoProviderUsageOptions,
): Promise<PaseoProviderUsageResult> {
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-02-28 once daemon floor >= v0.1.98.
if (daemonClient.getLastServerInfoMessage()?.features?.providerUsageList !== true) {
return Promise.reject(new Error("Update the host to list provider usage."));
}
return daemonClient.listProviderUsage(options);
}
function waitForProvidersReady(
daemonClient: DaemonClient,
options: PaseoProviderWaitOptions = {},
+1
View File
@@ -184,6 +184,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options
| `listModes(provider, options?)` | Modes result | Discovers permission or operating modes. |
| `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. |
| `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. |
| `listUsage(options?)` | `PaseoProviderUsageResult` | Returns normalized subscription windows, balances, and provider details. Rejects with an update-host error when unsupported. Options: `requestId`. |
| `subscribe(handler)` | Unsubscribe function | Listens for catalog updates. |
## `client.config`