From c43df5d4c398571f62584b4ad5a629b5fd3b4599 Mon Sep 17 00:00:00 2001 From: Ivan Brykov Date: Mon, 7 Sep 2026 12:52:34 -0500 Subject: [PATCH] feat(client): expose provider usage (#4062) * feat(client): expose provider usage * fix(client): gate provider usage by capability --- packages/client/src/index.test.ts | 73 ++++++++++++++++++++++++++++++- packages/client/src/index.ts | 18 ++++++++ public-docs/sdk/reference.md | 1 + 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7c02924c56..7cf75b5806 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -83,7 +83,10 @@ function parseSentFrame( } async function connectClient( - features: Record = { providersSnapshotCwd: true }, + features: Record = { + 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 = []; @@ -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(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 12615bd44f..729ea4bc94 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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; + listUsage(options?: PaseoProviderUsageOptions): Promise; 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 { + // 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 = {}, diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index bc726bd5be..0f8bf7200f 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -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`